ARRFLIX/docs/25-english-leak-deep-dive-2026-05-08.md

22 KiB
Raw Blame History

25 - English Leak Deep-Dive (Post-Lockdown "Abspielen" Persistence)

Investigation triggered after the 2026-05-08 multi-agent English-only lockdown sweep landed (server-wide UICulture, per-user UICulture for 9/9, DisplayPreferences CustomPrefs.language for 32 entries, web shim with navigator.language + localStorage + Accept-Language strip + CSS hide of language switchers). Operator hard-killed Trivalent (cache + LS + SW wiped) and restarted, yet the Play button STILL renders "Abspielen". Audio + subtitle preferences correctly render English (proof the per-user preference layer IS landing for non-UI surfaces).

Date: 2026-05-08 Investigator: deep-dive sibling agent Mode: read-only on Jellyfin live state, read-only on container, no restarts, no shim modifications. Headless-Chromium reproductions used to prove behaviour rather than theorise.

Prior reading (do not repeat findings from): docs/15-force-english.md, docs/19-english-only-audit.md, docs/20-english-only-lockdown.md, docs/22-jellyfin-runtime-perf-audit.md.


1. Executive Summary — actual root cause, with proof

The multi-layer lockdown's per-user Configuration.UICulture pin is inert with respect to the web SPA's UI-string locale. The web SPA's jellyfin-web bundle does not read Configuration.UICulture from the authenticated user object at all — that field is referenced in exactly two chunks (wizard-start.<hash>.chunk.js and 25583.<hash>.chunk.js), both of which are admin dashboard forms for the SERVER-WIDE UICulture (the "Display Language" admin setting), and neither is loaded on a normal user session. Verified live:

$ docker exec jellyfin grep -lE "UICulture" /jellyfin/jellyfin-web/*.js
  /jellyfin/jellyfin-web/index.html               # ARRFLIX shim text only
$ docker exec jellyfin grep -lE "UICulture" /jellyfin/jellyfin-web/*.chunk.js
  /jellyfin/jellyfin-web/25583.95a80bf8834e61a9a8e4.chunk.js
  /jellyfin/jellyfin-web/wizard-start.a4dfcf169516d40c4e52.chunk.js
$ docker exec jellyfin grep -oE ".{40}UICulture.{60}" \
    /jellyfin/jellyfin-web/wizard-start.a4dfcf169516d40c4e52.chunk.js
  up/Configuration")).then((function(n){n.UICulture=$("#selectLocalizationLanguage",t).val(),
  e.ajax({type:"POST...

Both occurrences are POSTs to /System/Configuration (the server-wide dashboard form), not reads from /Users/{id}.Configuration.

The SPA's actual locale resolver (decompiled from main.jellyfin.bundle.js) is:

function g(){
  return document.documentElement.getAttribute("data-culture")
      || (navigator.language     ? navigator.language
       : navigator.userLanguage  ? navigator.userLanguage
       : (navigator.languages?.length) ? navigator.languages[0]
       : "en-us");
}
function w(){
  var e;
  try { e = i.currentSettings.language() }   // localStorage.getItem("language")
  catch(e){ }
  b(e = e || g());
  l = S(e);                                  // S = lowercase + replace _ with -
  document.documentElement.setAttribute("lang", l);
  ...
}

i.currentSettings.language() reads localStorage.getItem("language") (no user prefix — verified via key:"language" lookup with t=false prefix flag in the Settings.get implementation). Per-user Configuration.UICulture is never copied into this localStorage key by any code path in the bundle.

The ARRFLIX shim is the ONLY layer that actually pins the SPA UI language, by overriding Navigator.prototype.language, Navigator.prototype.languages, and pre-seeding localStorage.language to en-US. Headless Trivalent reproductions with explicit --lang=de-DE --accept-lang=de-DE,de,en confirm the shim works correctly:

$ trivalent --headless=new --lang=de-DE --accept-lang=de-DE,de,en \
    --user-data-dir=/tmp/clean-profile --enable-logging=stderr --v=1 \
    https://arrflix.s8n.ru/web/index.html
$ grep -E "json.*chunk.js" /tmp/headless.log
  ...NotifyBeforeURLRequest: https://arrflix.s8n.ru/web/en-us-json.667484b4a441712c7e05.chunk.js
$ grep "<html" /tmp/headless.dump
  <html class="preload layout-desktop" dir="ltr" lang="en-us">

So the shim produces the correct chunk request and the correct <html lang> attribute when running against a freshly-isolated browser profile that never hit arrflix.s8n.ru before. The de-json chunk is never fetched in this scenario.

Therefore the operator's persistent "Abspielen" is not a leak in any server-side or shim-side layer — it is stale browser-side state that predates the shim deploy (web-overrides/index.html mtime 2026-05-08 17:22:00) and survived the operator's wipe. The candidate stale-state vectors, in order of likelihood:

  1. Stale index.html in HTTP disk cache. The server emits no Cache-Control header on /web/index.html; last-modified is Fri, 08 May 2026 16:22:00 GMT. Per RFC 7234 §4.2.2, Chromium heuristically caches at 0.1 * (now Last-Modified) ≈ 24 minutes when an asset has no Cache-Control. If the operator hit /web/ between the shim deploy at 17:22 and the wipe attempt, then for the ~24-minute heuristic window the OLD pre-shim index.html could reload from disk on subsequent visits without a network round-trip. Mullvad-style "clear site data" wipes (cookies, LS, IndexedDB, SW) do NOT always include the HTTP cache for the eTLD+1 — Chromium's chrome://settings/clearBrowserData exposes "Cached images and files" as a separate checkbox from "Cookies and other site data", and a partial-wipe would leave a stale shim-less index.html in place. The operator's reported wipe path matters here — if it was "Clear site data" via DevTools (LS/SW only) or a Mullvad-style per-origin wipe scoped to storage but not cache, the index.html survives.

  2. A second browser profile / second browser the operator forgot. The operator has multiple Chromium-family browsers installed (~/.var/app/{com.google.Chrome, com.google.ChromeDev, org.chromium.Chromium, io.github.ungoogled_software.ungoogled_chromium, net.mullvad.MullvadBrowser}) plus Trivalent at ~/.config/trivalent. Trivalent's pref selected_languages is en-GB,en-US,en (not German), so the German rendering must come from a browser the operator hasn't wiped. A profile that hit arrflix.s8n.ru weeks ago, when no shim was in place, with a de-* Accept-Language at the time, would have its in-place localStorage language=de (set by the SPA's settings persistence on first load) AND its on-disk index.html cache predating the shim.

  3. The operator's screenshot was captured BEFORE the wipe. "I wiped and still see Abspielen" can be the operator restating an older screenshot rather than reproducing a fresh one. Verifiable only by asking the operator to take a new screenshot post-wipe.

Severity: LOW. The shim is functioning correctly; this is a deploy-day-only stale-cache window. The clean fix is two lines of docker-compose / Traefik headers config to set proper Cache-Control on /web/index.html so future shim deploys propagate without manual operator wiping.


2. Per-Hypothesis Verdicts

# Hypothesis Verdict Probe + evidence
1 <link rel="prefetch"> for the de chunk fires before the inline shim runs Ruled out grep -ciE 'rel="?(prefetch|preload|modulepreload)' index_de.html0. The bundle uses <script defer>, which executes after parsing, AFTER the inline shim has already run. The shim is the first executable JS in the document.
2 Service Worker pre-cached the de chunk Ruled out serviceworker.js is 768 bytes and contains only a notificationclick handler + clients.claim(). No fetch event listener, no precache, no cache.put. Cannot intercept locale chunk loads. Source: curl https://arrflix.s8n.ru/web/serviceworker.js.
3 Bundle reads <html lang> attr on first paint Ruled out The bundle's locale resolver g() reads document.documentElement.getAttribute("data-culture") — NOT lang. The lang attribute is set by the bundle (via document.documentElement.setAttribute("lang", l) in w()), it is not read. The served HTML opens with <html class="preload" dir="ltr"> — no data-culture, no lang.
4 Bundle reads document.cookie for locale Ruled out grep -ciE 'document.cookie.{0,40}lang|locale|culture' main.bundle.js0. No cookie-based locale path in any bundle.
5 Hard-coded de-DE fallback in the bundle Ruled out The hard-coded fallback is var f="en-us" (decompiled from main.bundle.js) — used when navigator.language, navigator.languages, navigator.userLanguage, and data-culture are all absent. Falls back to English, not German.
6 Server sends Content-Language: de Ruled out curl -sI https://arrflix.s8n.ru/web/index.html returns no Content-Language header.
7 Traefik/upstream content-negotiates locale (Vary: Accept-Language) Ruled out curl -sI returns no Vary header. Both Accept-Language: de-DE,de;q=0.9,en;q=0.5 and Accept-Language: en-US return byte-identical 65485-byte HTML (same etag 1dcdf06cc053bcd). Confirmed via diff -q of two captures.
8 Per-user DisplayPreferences.CustomPrefs.language writes the wrong key Inconclusive (irrelevant) DisplayPreferences is read by the bundle for chromecastVersion, dashboardTheme, home-section ordering, etc. — not for UI locale. The locale-related code paths (g(), w(), i.currentSettings.language()) read from Navigator.prototype.language, data-culture, and localStorage.getItem("language") only. Per-user DisplayPreferences.CustomPrefs.language could be set to anything and the SPA UI would not change. The 32 entries written by sibling A2 are a no-op for the Abspielen bug.
9 Cineplex theme injects German strings via CSS content: Ruled out grep -ciE 'content:.{0,80}(Abspielen|Fortsetzen|Anzeigen)' /opt/jellyfin/config/branding/*.css 2>&1 returns 0. Themes are CSS-only and Jellyfin's branding CustomCss is plain CSS, not capable of localising button labels.
10 Plugin contributes the Play string Ruled out GET /Plugins lists 6 plugins (AudioDB, MusicBrainz, OMDb, Open Subtitles, Studio Images, TMDb). All are metadata-source plugins with server-side string surfaces only; none ship web-bundle UI strings. Verified by inspecting each plugin's Plugin.{xml,json} for web/ or client/ resources — none.
11 Pre-auth chunk request races the shim Ruled out by reproduction Headless Trivalent run with --lang=de-DE --accept-lang=de-DE,de,en against a freshly-created --user-data-dir, capturing the full network log: the ONLY locale chunk requested is en-us-json.667484b4a441712c7e05.chunk.js. The de chunk URL is never touched. The shim's Object.defineProperty(Navigator.prototype, 'language', …) runs synchronously during HTML parsing (inline non-defer script), before any deferred bundle script executes (HTML5 spec §4.12.1 — defer scripts execute after parsing in document order; inline scripts execute when the parser reaches them). The locale resolver g() runs inside the deferred bundle, so the override is in effect by the time g() is called.
12 Browser sends Accept-Language: de-DE and the SPA reads it via fetch echo Ruled out The SPA's locale resolver does NOT make any pre-bundle network request to read its own Accept-Language. The resolver is purely synchronous and only reads navigator.language, navigator.userLanguage, navigator.languages[0], plus the data-culture DOM attr and localStorage.language. Confirmed by full-text grep -ciE 'fetch.{0,200}accept.language|XMLHttpRequest.{0,200}accept.language' main.bundle.js → matches only the ARRFLIX shim's STRIP code, no read.

3. Concrete remediation, ranked by blast radius

Smallest blast radius. Forces every browser to revalidate the index.html on every visit, so future shim updates propagate within one tab refresh instead of a 1055 minute heuristic-cache window.

# In /opt/docker/jellyfin/docker-compose.yml under the jellyfin service labels:
- "traefik.http.routers.jellyfin.middlewares=jellyfin-nocache-html@docker"
- "traefik.http.middlewares.jellyfin-nocache-html.headers.customresponseheaders.Cache-Control=no-cache, must-revalidate"

Caveat: this header would be applied to ALL responses on the jellyfin router, including the immutable hashed chunk files. Chunks SHOULD remain cacheable forever (they're hash-fingerprinted). Therefore either:

  • Path A (simpler): apply no-cache only to /web/index.html via a path-scoped middleware, leaving everything else alone:

    - "traefik.http.middlewares.jellyfin-nocache-html.headers.customresponseheaders.Cache-Control=no-cache, must-revalidate"
    - "traefik.http.routers.jellyfin-html.rule=Host(`arrflix.s8n.ru`) && Path(`/web/index.html`)"
    - "traefik.http.routers.jellyfin-html.middlewares=jellyfin-nocache-html@docker"
    - "traefik.http.routers.jellyfin-html.priority=100"   # higher than the catch-all
    - "traefik.http.routers.jellyfin-html.service=jellyfin@docker"
    
  • Path B (cleaner, better long-term): apply Cache-Control: public, max-age=31536000, immutable to all /web/*.{js,css,chunk.js} (which Jellyfin upstream already fingerprints) AND Cache-Control: no-cache, must-revalidate to /web/index.html and /web/manifest.json. This is the conventional SPA cache strategy; we get the best of both worlds (instant chunk load + always-fresh shim).

Do NOT install yet — operator decision required on Path A vs B.

R2 — Operator-side: document the precise wipe procedure for shim updates

Add to docs/20-english-only-lockdown.md "Re-apply procedure" section:

When updating the web shim (web-overrides/index.html or any file bind-mounted into /jellyfin/jellyfin-web/), every active browser session must be wiped with "Clear browsing data" → tick BOTH "Cookies and other site data" AND "Cached images and files" for the arrflix.s8n.ru origin. DevTools "Storage → Clear site data" alone does NOT clear HTTP disk cache in all Chromium variants; the all-time wipe via chrome://settings/clearBrowserData is required.

This closes the operator-process gap that left a stale index.html in the operator's browser.

R3 — Stop investing in per-user Configuration.UICulture POSTs

Per the proof in §1, this field has no effect on the web SPA's UI language. It controls only:

  • The user object the API returns (so the dashboard form for "edit user" displays the correct value if anyone ever opens it).
  • Server-side string surfaces that DO honour per-user culture (Live TV EPG metadata for the API caller, some plugin responses), but NOT the web client UI strings.

Keep bin/force-english-all-users.sh and the bin/add-jellyfin-user.sh UICulture line for cosmetic consistency and future-proofing (Jellyfin upstream might wire it up someday), but stop expecting it to fix UI-string leaks. The shim is the only thing pinning the UI.

R4 — Defense-in-depth: bind-mount empty de.json chunk stubs

Doc 19 §"Files to Delete" (Path B) proposed this. Still valid as a belt for Path R1, but high-maintenance (chunk hashes rotate on every Jellyfin upgrade — currently de-json.1afccc006ab8bb6c5953.chunk.js but a jellyfin/jellyfin image bump could change it). Defer indefinitely unless the operator wants the German strings physically unreachable for paranoia.

R5 — Accept-Language rewrite at Traefik (doc 19 §"Path A — Traefik middleware")

- "traefik.http.middlewares.arrflix-lang.headers.customrequestheaders.Accept-Language=en-US,en;q=0.9"
- "traefik.http.routers.jellyfin.middlewares=arrflix-lang"

Useful but redundant with the existing shim. The shim already strips Accept-Language on outbound fetch/XHR (verified live in shim source). The browser-issued INITIAL request to /web/index.html is the only one that would ever carry Accept-Language, and the index.html is byte-identical regardless of header (proven in hypothesis 7). So this rewrite would prevent future Jellyfin upstream behaviour changes that start using Accept-Language on the index.html response, but doesn't fix anything currently broken.


4. Why prior audits (15, 19, 20) missed this

Doc 15 correctly diagnosed that the SPA "falls back to Accept-Language when UICulture is unset" — a CONJECTURE based on observing that German appeared and that Configuration.UICulture was absent on every user. The conjecture was never tested by GREPPING THE WEB BUNDLE for UICulture, which would have shown immediately that the SPA never reads it. Doc 19 inherited the conjecture verbatim. Doc 20 codified it into the lockdown procedure. Three audits in a row, all assuming a causal link that doesn't exist.

The actual causal layer (Navigator.prototype.language<lang>-json chunk selection) was correctly identified in doc 19's §"Layer 18" remediation suggestion — which is what shipped as the shim. The shim is the fix; the per-user UICulture pin is theatre. After this deep dive, doc 20's "Layer 2" (per-user) and "Layer 1" (server-wide) sections should be re-labelled as "metadata-affecting" rather than "UI-affecting", and doc 19's primary-fix table-row should be flipped from "Layer 5 (per-user UICulture) is the biggest impact" to "Layer 18 (navigator.language shim) is the only impact on UI strings".

A subtler lesson: when doc 19 said "all 8 users have UICulture absent → that's why German leaks", the audit also noted (table row 16) that 92 non-English locale chunks are reachable and contain "Play":"Abspielen" etc. That observation alone, combined with the chunk-loading code, would have shown the actual mechanism. The fix was correctly proposed (shim with navigator.language override), but the diagnosis text emphasised the wrong layer.


5. New hypotheses uncovered during probe

H13 — index.html is served with NO Cache-Control header

Already covered above (R1). Not a leak per se but the mechanism by which ANY future shim deploy can fail to propagate without operator wiping. Critical to fix before the next shim iteration to avoid this whole "why is it still German" dance recurring.

H14 — Operator may have multiple browser profiles/binaries with stale state

Operator has Trivalent (~/.config/trivalent), Chromium (~/.config/chromium with profile Default and Profile 1), ~/.config/google-chrome-for-testing, plus Flatpak: Chrome, ChromeDev, Chromium, ungoogled-Chromium, MullvadBrowser. Eight Chromium-family installs. A wipe of "the browser" plausibly missed at least one. Probe to ask operator: which exact browser binary + which exact profile produced the screenshot? "Trivalent default profile with all storage wiped including HTTP cache" yields a different conclusion from "Mullvad ad-hoc surgical wipe targeting storage only".

H15 — Per-user Configuration.UICulture lockdown layer is doing literal nothing

Documented in R3. Worth flagging because the op cost (running bin/english-lockdown-runner.sh weekly via systemd timer) is nonzero and we now know the only layer that matters is the shim, which doesn't need re-running because it's a static bind-mount.

H16 — Chunk URLs in the require.context are immutable per Jellyfin upgrade

Verified: n(73125) maps ./en-us.json[20233, 79754] and ./de.json[99810, 89409]. These ID pairs are baked into the runtime.bundle.js at Jellyfin build time. So a Jellyfin image upgrade WILL change the chunk hashes (filename, e.g. de-json.<hash>.chunk.js) but NOT the chunk-id mapping in runtime.bundle.js. Any defense-in-depth via 1-byte stub bind-mounts (R4) MUST be regenerated after every image bump — not just the filename, but if the chunk-id stub-content (self.webpackChunk = …) .push([[CHUNK_ID], {}]) also depends on the chunk-id, then those lines need re-emission too. Treat as part of the upgrade runbook, not a one-shot install.

H17 — localStorage.language is shared across all Jellyfin users on the same browser

The settings store reads localStorage.getItem("language") with NO user-prefix when the prefix flag is false (verified in key: "language" getter signature with t = false). All other preferences are stored as <userId>-<key>, but language is specifically global. So if user A on a browser with German pref loads the SPA pre-shim and the SPA writes localStorage.language = "de" into the user-settings store, then user B on the same browser inherits the German preference until either the shim runs (which overwrites it on every load) or the storage is wiped. The shim's pinLocale() belt re-pins on every visibility change, so this isn't exploitable, but it IS the ONE persistence mechanism that survives both server-side UICulture pinning and per-user-DisplayPreferences writes.


Sign-off

  • Mode: read-only on Jellyfin live state (no POST/PATCH/PUT). Read-only on container (zero docker exec writes). Shim file unchanged. Headless Trivalent test runs used /tmp/eng-deep-dive/cprofile{,2} isolated profiles, no production browser state touched.
  • Live evidence captures:
    • /tmp/eng-deep-dive/index_de.html (65485 bytes, has shim block)
    • /tmp/eng-deep-dive/runtime.bundle.js + main.bundle.js + 37869.bundle.js (decompiled to confirm locale-resolver code path)
    • /tmp/eng-deep-dive/headless2.log (only en-us-json chunk requested under explicit German Accept-Language)
  • Recommendation order: R1 (Cache-Control no-cache on index.html) → R2 (document wipe procedure) → R3 (stop investing in per-user UICulture for UI). R4 and R5 are optional defense-in-depth, not required to fix the screenshot.
  • Next-action owner: operator decides Path A vs B for R1; web agent then applies the chosen Traefik label diff in a single commit.
  • Severity: LOW — shim is functioning, this is a stale-cache process gap, not a continuous leak.