373 lines
22 KiB
Markdown
373 lines
22 KiB
Markdown
# 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:
|
||
|
||
```js
|
||
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.html` → `0`. 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.js` → `0`. 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
|
||
|
||
### R1 — Add `Cache-Control: no-cache` on `/web/index.html` (Traefik header) — RECOMMENDED FIRST
|
||
|
||
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 10–55 minute heuristic-cache window.
|
||
|
||
```yaml
|
||
# 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:
|
||
|
||
```yaml
|
||
- "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")
|
||
|
||
```yaml
|
||
- "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.
|