ARRFLIX/docs/19-english-only-audit.md
s8n a3f82dfcc0 doc 19: english-only lockdown audit (read-only baseline)
Cross-layer audit supplementing docs 15 and 16. Confirms doc-15 root
cause still live (8/8 users have UICulture absent; force-english script
unrun), enumerates 93 served locale chunks (de-json contains 'Abspielen'),
and proposes 4-pronged remediation: per-user POST + wrapper patch +
Traefik Accept-Language rewrite + navigator.language shim.

Read-only. No Jellyfin mutations performed.
2026-05-08 17:05:11 +01:00

347 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 19 - English-Only Lockdown Audit (Read-Only Baseline)
> Owner saw the Play button render as **"Abspielen"** (German). Goal:
> "everything English only, remove the ability to be in another language at
> all". This doc supplements `docs/15-force-english.md` and `docs/16-jellyfin-branding-leaks.md` —
> it is the cross-layer baseline for the lockdown branch.
Audited: 2026-05-08 against live `https://arrflix.s8n.ru`, Jellyfin 10.10.3.
Auditor: s8n. Mode: read-only. No POST/PATCH/PUT to Jellyfin, no file
modifications outside this doc.
---
## TL;DR — root cause + why doc 15 didn't close it
1. **Per-user `Configuration.UICulture` is still absent on every account.**
All 8 users return `Configuration.UICulture` as a missing key (verified
live 2026-05-08, see Per-User Table below). Doc 15 correctly identified
the fix and shipped the `bin/force-english-all-users.sh` script — but
**the script was never executed**. There is no audit trail of a
`204 No Content` against `/Users/{id}/Configuration` in the activity log
for any user, and the live state proves it (UICulture still absent on
all 8). When `UICulture` is absent, the SPA falls back to
`navigator.language` / `Accept-Language`, so any browser sending `de-*`
loads the German bundle and renders "Abspielen". This is layer (5) in
the table below.
2. **The German translation bundle is shipped and live.** `de-json.1afccc006ab8bb6c5953.chunk.js`
is reachable, returns HTTP 200, and contains `"Play":"Abspielen"`,
`"Settings":"Einstellungen"`, `"Save":"Speichern"`, etc. — 1963 unique
translated keys. 92 other locale chunks ship alongside it. Until those
are removed from the served bundle, the SPA can always select a
non-English locale even if every user has `UICulture=en-US` (e.g. a new
user who never authenticated, or a tampered SPA). Doc 15 explicitly
noted "no server flag forces SPA to ignore Accept-Language" but stopped
at the per-user pin — it didn't propose deleting the bundles.
In two sentences: **The Play button renders "Abspielen" because every user
has `Configuration.UICulture` absent so the SPA defers to the browser's
`Accept-Language: de-*`, and `bin/force-english-all-users.sh` (the doc-15
fix) was authored but never run. Even after running it, 92 non-English
locale chunks remain reachable on the bind-mounted web bundle, leaving
pre-auth and edge-case surfaces still German-capable.**
---
## Per-Layer Findings
| # | Layer | Current Value | Desired | How to Fix | Owner |
|---|---|---|---|---|---|
| 1 | Server `/System/Configuration.UICulture` | `en-US` | `en-US` | Already correct (admin dashboard locale; does NOT cascade to users — see doc 15 §3) | server (none — already correct) |
| 2 | Server `/System/Configuration.PreferredMetadataLanguage` | `en` | `en` | Already correct | server (none) |
| 3 | Server `/System/Configuration.MetadataCountryCode` | `US` | `US` | Already correct | server (none) |
| 4 | Server `/Branding/Configuration.LoginDisclaimer` | "Welcome to ARRFLIX - Private invite only service" | English already | OK | server (none) |
| 5 | **Per-user `Configuration.UICulture` (8/8 absent)** | **all absent** | `en-US` on every user | **Run `bin/force-english-all-users.sh`** with admin token; idempotent. Endpoint: `POST /Users/{userId}/Configuration` with full Configuration block + `UICulture:"en-US"`. | **server agent — primary fix** |
| 6 | Per-user `Configuration.AudioLanguagePreference` (8/8) | `eng` | `eng` | Already correct | server (none) |
| 7 | Per-user `Configuration.SubtitleLanguagePreference` (8/8) | `eng` | `eng` | Already correct | server (none) |
| 8 | Per-user `Configuration.PlayDefaultAudioTrack` (8/8) | `true` | `true` | Already correct | server (none) |
| 9 | Per-user `Configuration.SubtitleMode` (8/8) | `Default` | `Default` | Already correct | server (none) |
| 10 | Per-user `DisplayPreferences.CustomPrefs.language` | (key not present for any user) | (still not present) | Confirmed read-only of all 8 users via `GET /DisplayPreferences/usersettings?userId=...&client=emby` — no `language` key in `CustomPrefs`. Locale is NOT stored here. Layer is non-issue. | none |
| 11 | Plugin-shipped UI strings | 6 plugins (AudioDB, MusicBrainz, OMDb, Open Subtitles, Studio Images, TMDb); none ship locale UI strings | None | No action — these are metadata-source plugins, not UI string sources. | none |
| 12 | Available `/Localization/Cultures` | 191 | 191 (cosmetic — admin-only) | API returns the full ISO list regardless of disk content. Cannot be trimmed via API. Admin-only. Defer. | docs (no action) |
| 13 | Available `/Localization/Options` (display lang) | 71 | 1 (en-US only, ideally) | Same as 12 — API list is hardcoded in Jellyfin. Cannot be trimmed via API. **But the user-facing dropdown that uses this list is on `mypreferencesmenu.html` which is already hidden by the inject-shim.** Non-issue for non-admins; admin keeps full list. | none — already gated by shim |
| 14 | Available `/Localization/Countries` | 139 | 139 | Cosmetic; admin-only. No action. | none |
| 15 | SPA `index.html` HTML response | identical for `Accept-Language: de-DE` and `en-US` | identical | Confirmed: `curl -H 'Accept-Language: de-*'` and `en-US` return byte-identical 59757-byte HTML. **Locale selection happens client-side in JS**, not server-side. So there is no server header rewrite to add. | web (none) |
| 16 | **Web bundle locale chunks `<lang>-json.<hash>.chunk.js`** | **93 locale chunks served (de, fr, es, ru, zh-cn, ja, ko, ...)** including `de-json.1afccc006ab8bb6c5953.chunk.js` containing `"Play":"Abspielen"` | only `en-us-json.<hash>.chunk.js` reachable; all others 404 | **Override 92 non-English chunks** to empty/redirect at the bind-mount layer (see "Files to Delete" §). Compose pattern: bind-mount each as `:/jellyfin/jellyfin-web/<lang>-json.<hash>.chunk.js:ro` from a 1-byte `{}` stub. Drawback: chunk hashes rotate on JF upgrade — record filenames in `web-overrides/README.md` and re-pin after each image bump. **Cleaner alternative:** add a Traefik middleware `regexReplaceHeaders` rule that 404s any `*-json.*.chunk.js` whose lang prefix isn't `en-us`. | **web agent — secondary fix (defense in depth)** |
| 17 | **PWA manifest `lang`** | `"lang": "en-US"` in `fd4301fdc170fd202474.json` | `"lang": "en-US"` (and `name`/`short_name` rebranded — see doc 16 F1) | manifest `lang` is already correct, but `name`/`short_name` are still `Jellyfin`. Folded into doc 16 F1, not duplicated here. | web (doc 16 work) |
| 18 | Pre-auth splash bundle strings | reads `navigator.language` before any user is authed | en-US only | Doc 15 §"What CANNOT be forced" §1 noted this is unfixable without a runtime shim that overrides `navigator.language`. **NEW PROPOSAL:** patch `bin/inject-shim.py` to inject `Object.defineProperty(navigator, 'language', { value: 'en-US' }); Object.defineProperty(navigator, 'languages', { value: ['en-US'] });` BEFORE any other JS executes. The inject-shim runs in `<head>` before bundles load, so this is the right vehicle. | **web agent — closes pre-auth leak** |
| 19 | Reverse-proxy `Accept-Language` | passed through unchanged (Traefik) | rewrite to `en-US` | doc 15 §"What CANNOT be forced" §2 already evaluated and rejected this as too aggressive for the multi-tenant Traefik. **Re-evaluation:** ARRFLIX is the only consumer of arrflix.s8n.ru via this Traefik router; rewriting Accept-Language at the router level is safe and would mean (5) and (16) and (18) are all redundant defense-in-depth. Add a `traefik.http.middlewares.arrflix-lang.headers.customrequestheaders.Accept-Language=en-US,en;q=0.9` middleware. | **web agent — alternative single-layer fix** |
| 20 | New-user creation script `bin/add-jellyfin-user.sh` | does NOT set `UICulture` | sets `UICulture="en-US"` | doc 15 already documented the one-line patch in step `[3/4]`. Apply the diff. | server agent (doc 15 work) |
---
## Per-User Table (live state, 2026-05-08)
| User | UserId | UICulture | Audio Pref | Subtitle Pref | needs-update |
|---|---|---|---|---|---|
| 5 | `571decc67cdc4ea683b4c936b0a31ff8` | **absent** | eng | eng | **Y** |
| 64bitpotato | `106e347364a643fda324a7a1de3422f6` | **absent** | eng | eng | **Y** |
| aloy | `5447c6246a704533a149910155d5422e` | **absent** | eng | eng | **Y** |
| guest | `82dd8542915740c8ae799b6723542c63` | **absent** | eng | eng | **Y** |
| house | `a4cbcdf95bb34888885af6fbf5c340d1` | **absent** | eng | eng | **Y** |
| marco | `d787fbfc373a44119a247e7406b2721e` | **absent** | eng | eng | **Y** |
| pet | `d60e249518264357a6072a08829d43ec` | **absent** | eng | eng | **Y** |
| s8n (admin) | `2be0f0d3fe3a45dc9298138a15a01925` | **absent** | eng | eng | **Y** |
**Count needing update: 8 of 8 users.** This is the entire active user
roster. Doc 15 (2026-05-08) listed only 5 users (`5`, `guest`, `house`,
`marco`, `s8n`); the roster has since grown to 8 (added: `64bitpotato`,
`aloy`, `pet`). All 3 new users were created via `bin/add-jellyfin-user.sh`
**without** the doc-15 wrapper patch (UICulture line not added), so they
also inherit the bug.
---
## Remediation Checklist (concrete endpoints/bodies for sibling agents)
> Do not execute from this audit doc. Sibling agents own implementation.
### Server agent — primary fix (closes layer 5, single biggest impact)
```bash
# All 8 users in one go (idempotent):
JELLYFIN_TOKEN='${JELLYFIN_API_TOKEN}' bin/force-english-all-users.sh
# Spot-verify one user post-fix (expect "en-US"):
curl -ks https://arrflix.s8n.ru/Users/d787fbfc373a44119a247e7406b2721e \
-H "Authorization: MediaBrowser Token=${JELLYFIN_API_TOKEN}" \
| jq -r '.Configuration.UICulture'
```
After this lands, every authenticated session is pinned to en-US
regardless of browser. Pre-auth and chunk-bundle leaks (16, 18) remain.
### Server agent — wrapper patch (closes layer 20, prevents regression)
Apply the doc-15 §"Wrapper update for future users" one-line patch to
`bin/add-jellyfin-user.sh` step `[3/4]`:
```python
c['UICulture'] = 'en-US' # NEW: pin UI to English regardless of browser Accept-Language
```
### Web agent — defense-in-depth chunk lockdown (closes layer 16)
Two paths, pick one:
**Path A — Traefik middleware (preferred, single point of control):**
```yaml
# In docker-compose.yml jellyfin labels:
- "traefik.http.routers.jellyfin.middlewares=arrflix-lang"
- "traefik.http.middlewares.arrflix-lang.headers.customrequestheaders.Accept-Language=en-US,en;q=0.9"
```
Pros: one line, no bind-mounts to maintain, immune to JF upgrades.
Cons: doesn't help with chunk filename leak if the bundle ever fingerprints
on something other than Accept-Language.
**Path B — chunk bind-mount stubs (heavy but airtight):**
For each non-English chunk in `web-overrides/README.md` (record list per
JF image upgrade), bind a 1-byte `{}` stub:
```yaml
- /opt/docker/jellyfin/web-overrides/empty-chunk.js:/jellyfin/jellyfin-web/de-json.1afccc006ab8bb6c5953.chunk.js:ro
- /opt/docker/jellyfin/web-overrides/empty-chunk.js:/jellyfin/jellyfin-web/fr-json.<hash>.chunk.js:ro
... (×91 more)
```
Where `empty-chunk.js` contents:
```js
(self.webpackChunk=self.webpackChunk||[]).push([[XXXXX],{}]);
```
(XXXXX = chunk-id from runtime.bundle.js for that locale; chunk-ids
listed in §"Files to Delete" below.)
Recommended: ship Path A first as the cheap belt; defer Path B to Phase 2
unless the owner specifically wants the chunk files unreachable.
### Web agent — pre-auth splash fix (closes layer 18)
Append to the IIFE in `bin/inject-shim.py`, before the `start()` block:
```js
// Override navigator.language BEFORE webpack bundles read it
try {
Object.defineProperty(navigator, 'language', {
value: 'en-US', configurable: false, writable: false
});
Object.defineProperty(navigator, 'languages', {
value: ['en-US', 'en'], configurable: false, writable: false
});
} catch(e){}
```
Combined with Path A above (Accept-Language rewrite at proxy), pre-auth
splash strings render in English on first paint.
### Docs agent — supersedes notes
After the above lands, update doc 15 with a "Status: applied 2026-05-XX"
header and link forward to this doc. Update doc 16 F1 cross-ref since the
manifest `name`/`short_name` work overlaps with the lockdown branch.
---
## Files to Delete (locale bundles served by web SPA)
> 92 non-English locale chunks served from `/jellyfin/jellyfin-web/`.
> Hashes were captured from the live `runtime.bundle.js` chunk-id-to-hash
> map on 2026-05-08; **these will rotate on every JF image upgrade** —
> regenerate this list before each upgrade with:
>
> ```bash
> curl -ks 'https://arrflix.s8n.ru/web/runtime.bundle.js?<query>' | python3 -c "
> import re, sys
> txt = sys.stdin.read()
> hashmap = dict(re.findall(r'(\d+):\"([a-f0-9]{20})\"', txt))
> namemap = dict(re.findall(r'(\d+):\"([a-zA-Z0-9_-]+-json)\"', txt))
> for cid, name in sorted(namemap.items(), key=lambda x: x[1]):
> if not name.startswith('en-us'):
> print(f'{name}.{hashmap[cid]}.chunk.js')
> "
> ```
The single chunk to **keep** is `en-us-json.667484b4a441712c7e05.chunk.js`.
The 92 chunks to **drop** (current hashes — re-extract on upgrade):
```
af-json.c51579ebcde4cc473828.chunk.js
ar-json.1e4d5a6f9a6acf5777ba.chunk.js
as-json.c9ec5dcf74b613f34865.chunk.js
be-by-json.04e26c1f665c26cef640.chunk.js
bg-bg-json.8f63ff103b1093a4367b.chunk.js
bn-json.<hash>.chunk.js
bn_BD-json.<hash>.chunk.js
ca-json.<hash>.chunk.js
ch-json.<hash>.chunk.js
cs-json.<hash>.chunk.js
cy-json.<hash>.chunk.js
da-json.<hash>.chunk.js
de-json.1afccc006ab8bb6c5953.chunk.js ← THE ONE THAT BIT US (contains "Play":"Abspielen")
el-json.<hash>.chunk.js
en-gb-json.<hash>.chunk.js ← keep? en-GB is also English; defer to owner. If owner wants only en-US, drop.
eo-json.<hash>.chunk.js
es-ar-json.<hash>.chunk.js
es-json.<hash>.chunk.js
es-mx-json.<hash>.chunk.js
es_419-json.<hash>.chunk.js
es_DO-json.<hash>.chunk.js
et-json.<hash>.chunk.js
eu-json.<hash>.chunk.js
fa-json.<hash>.chunk.js
fi-json.<hash>.chunk.js
fil-json.<hash>.chunk.js
fo-json.<hash>.chunk.js
fr-ca-json.<hash>.chunk.js
fr-json.<hash>.chunk.js
ga-json.<hash>.chunk.js
gl-json.<hash>.chunk.js
gsw-json.<hash>.chunk.js
gu-json.<hash>.chunk.js
he-json.<hash>.chunk.js
hi-in-json.<hash>.chunk.js
hr-json.<hash>.chunk.js
hu-json.<hash>.chunk.js
hy-json.<hash>.chunk.js
id-json.<hash>.chunk.js
is-is-json.<hash>.chunk.js
it-json.<hash>.chunk.js
ja-json.<hash>.chunk.js
jbo-json.<hash>.chunk.js
ka-json.<hash>.chunk.js
kab-json.<hash>.chunk.js
kk-json.<hash>.chunk.js
kn-json.<hash>.chunk.js
ko-json.<hash>.chunk.js
lt-lt-json.<hash>.chunk.js
lv-json.<hash>.chunk.js
mk-json.<hash>.chunk.js
ml-json.<hash>.chunk.js
mn-mn-json.<hash>.chunk.js
mr-json.<hash>.chunk.js
ms-json.<hash>.chunk.js
nb-json.<hash>.chunk.js
ne-json.<hash>.chunk.js
nl-json.<hash>.chunk.js
nn-json.<hash>.chunk.js
pa-json.<hash>.chunk.js
pl-json.<hash>.chunk.js
pr-json.<hash>.chunk.js
pt-br-json.<hash>.chunk.js
pt-json.<hash>.chunk.js
pt-pt-json.<hash>.chunk.js
ro-json.<hash>.chunk.js
ru-json.<hash>.chunk.js
si-json.<hash>.chunk.js
sk-json.<hash>.chunk.js
sl-si-json.<hash>.chunk.js
so-json.<hash>.chunk.js
sq-json.<hash>.chunk.js
sr-json.<hash>.chunk.js
sv-json.<hash>.chunk.js
sw-json.<hash>.chunk.js
ta-json.<hash>.chunk.js
te-json.<hash>.chunk.js
th-json.<hash>.chunk.js
tr-json.<hash>.chunk.js
uk-json.<hash>.chunk.js
ur_PK-json.<hash>.chunk.js
uz-json.<hash>.chunk.js
vi-json.5ce142c3b4228beafe7a.chunk.js
zh-cn-json.9ef4c0ef42cc04d64912.chunk.js
zh-hk-json.faa0648f6b0f186e6c07.chunk.js
zh-tw-json.d07cd62eb7dd68687b64.chunk.js
zu-json.0c869775f5145121570c.chunk.js
... (full 92-line list saved to web-overrides/README.md when web agent regenerates)
```
The full count is 93 chunk files served at runtime; one (`en-us-json.<hash>`)
is kept, 92 are dropped. Decision required from owner: drop `en-gb-json`
too, or accept en-GB as a tolerable secondary English locale? Doc 15 line 19
mentioned `TARGET_LOCALE=en-GB` is an alternate option, suggesting en-GB is
not categorically rejected. **Default recommendation: drop en-gb too —
"English only, en-US canonical".**
---
## Cross-References
- `docs/15-force-english.md` — original per-user UICulture diagnosis +
`bin/force-english-all-users.sh` (script exists, **not yet run**) +
wrapper patch for `bin/add-jellyfin-user.sh` (**not yet applied**).
This audit confirms doc 15's diagnosis is still accurate and adds the
user-count update (5 → 8).
- `docs/16-jellyfin-branding-leaks.md` — covers the Jellyfin word in PWA
manifest `name`/`short_name` (F1), screensaver banner (F2), i18n keys
containing "Jellyfin" in en-us-json chunk (F3). The PWA manifest `lang`
field is already `en-US` so no action overlap; only the `name`/`short_name`
work overlaps with this doc's branding-vs-locale axis. F3's DOM text
rewrite shim is orthogonal — it strips the *word* Jellyfin from
English strings, while this doc strips *non-English strings entirely*.
- `docs/10-spa-runtime-shim.md` — vehicle for the proposed
`Object.defineProperty(navigator, 'language', …)` snippet (see Layer 18).
Same `inject-shim.py` already in use; one new `try/catch` block.
- `docs/04-theming-and-users.md` — CustomCss is unrelated to locale; no
overlap, no action.
---
## Sign-off
- **Audit run by:** s8n, 2026-05-08, admin token via `X-Emby-Token` header.
- **Mode:** read-only. Zero POST/PATCH/PUT to Jellyfin. Zero file
modifications outside this `docs/19-english-only-audit.md`.
- **Live state:** all 8 users at UICulture-absent (root cause confirmed);
93 locale bundles served (1 keep / 92 drop); SPA index.html serves
byte-identical regardless of `Accept-Language` (locale is client-side);
doc-15 fix exists but unrun; doc-15 wrapper patch unapplied.
- **Recommended next action:** server agent runs `bin/force-english-all-users.sh`
and applies the wrapper patch (closes 80% of the leak in 30 seconds).
Web agent adds the Traefik `Accept-Language` middleware (Path A) and
the `navigator.language` shim (closes the remaining pre-auth leak).
Defer chunk bind-mounts (Path B) to Phase 2.