security(#197): fail-closed API auth posture (Bundle A: #280/#281/#282/#284/#285) #292

Merged
timothy merged 7 commits from bundle-a/197-auth-posture into main 2026-07-12 00:34:45 +02:00
Owner

Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure. Bundle A per the #197 bundling map (one PR, shared surface). Closes #280, #281, #282, #284, #285.

Owner decisions (confirmed this session)

  • Single API key (no read/write split).
  • Api:RequireKeyForReads defaults true — the whole /api surface requires X-Api-Key. Verified this does NOT affect Jellyfin/streaming: /iptv/* (playlist/guide/streams/logos) and /artwork/* are outside the filter's /api scope and keep their own optional access-token; only the SPA-facing management API is gated.
  • First-run/upgrade UX consequence: with reads gated by default, the SPA shows no data until the key (auto-generated at /config/api.key) is entered on the new keyless API Key screen — an intended effect of the strict default.

What changed

  • #280 S1 (BLOCKER) — fail-closed writes. Deleted the empty-key "open" branch. New IApiKeyProvider/ApiKeyProvider (singleton, resolved once at startup) yields a never-empty key: Api:WriteKey → else persisted /config/api.key (0600) → else a generated 256-bit hex key (path logged, never the value). No open mode.
  • #282 S3/S5 — sensitive-read tier. Reads gated when Api:RequireKeyForReads (default true) OR the endpoint has the new [RequiresApiKey] (applied to Troubleshoot/Logs/Settings/Maintenance so they stay gated even if reads are opened). OPTIONS exempt.
  • #281 S2 — deleted dead bypass surfaces. SortController (POST media/collections/{id}/items, dead Blazor residue — SPA uses PUT /api/collections/{id}/custom-order) and AccountController (POST account/logout, dead OIDC). Both were non-/api persistent mutations that bypassed the key.
  • #284 S6 — CORS opt-in. AllowAll → Api:CorsAllowedOrigins exact-origin allowlist (permits X-Api-Key/If-Match, exposes ETag); default no cross-origin (SPA is same-origin).
  • #285 S7/S10 — residue. gc GET→POST (spec regenerated); ForwardedHeaders trust configurable via ForwardedHeaders:KnownProxies/KnownNetworks (unconfigured = current trust-all + a warning — flipping to loopback-only would break reverse-proxy M3U/XMLTV URL generation, so the operator names their proxy net); ScannerController gains [LocalhostOnly] (scanner always calls back over localhost; interlocks with restricting ForwardedHeaders trust). search/all-items DoS-paging deferred (feeds the SPA add-all flow; unauth exposure already closed by read-gating).
  • SPA. Client sends the key on every method; keyless /app/api-key screen; shell-level 401 banner. (spa-conventions §5e.)

Verification

  • Unit/integration: ErsatzTV.Tests 1420/1420 green (new: filter fail-closed/read-gating/tier/OPTIONS tests + reflective tier assertions in ApiControllerSecurityTests). Web 692/692, typecheck/lint/build clean, check:api no drift.
  • Live E2E (fresh instance): api.key written 0600 (256-bit); GET /api/channels no-key → 401, correct key → 200, wrong key → 401; GET /api/settings/ffmpeg no-key → 401; GET /iptv/channels.m3u no-key → 200 (Jellyfin unaffected); POST /api/maintenance/gc no-key → 401 / with key → 200; old GET gc → 404; OPTIONS /api/channels not blocked; /app/api-key serves keyless; deleted Sort/Account POSTs → no handler.

Docs (same PR)

docs/api-conventions.md §9 (auth posture) + §6 (test bullet); docs/spa-conventions.md §5e; docs/decisions.md (Bundle A entry); regenerated v1.json + endpoint-index.md.

Deferred (Phase-2/3, not this bundle)

Declarative OpenAPI security scheme + global 401 docs + /api/v1 versioning (#286/#287/#288); search/all-items paging, per-key rate limiting, #265/#269/#172 remainder.

🤖 Generated with Claude Code

Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure. Bundle A per the #197 bundling map (one PR, shared surface). Closes #280, #281, #282, #284, #285. ## Owner decisions (confirmed this session) - **Single API key** (no read/write split). - **`Api:RequireKeyForReads` defaults `true`** — the whole `/api` surface requires `X-Api-Key`. **Verified this does NOT affect Jellyfin/streaming**: `/iptv/*` (playlist/guide/streams/logos) and `/artwork/*` are outside the filter's `/api` scope and keep their own optional access-token; only the SPA-facing management API is gated. - **First-run/upgrade UX consequence**: with reads gated by default, the SPA shows no data until the key (auto-generated at `/config/api.key`) is entered on the new keyless **API Key** screen — an intended effect of the strict default. ## What changed - **#280 S1 (BLOCKER) — fail-closed writes.** Deleted the empty-key "open" branch. New `IApiKeyProvider`/`ApiKeyProvider` (singleton, resolved once at startup) yields a never-empty key: `Api:WriteKey` → else persisted `/config/api.key` (`0600`) → else a generated 256-bit hex key (path logged, never the value). No open mode. - **#282 S3/S5 — sensitive-read tier.** Reads gated when `Api:RequireKeyForReads` (default true) OR the endpoint has the new `[RequiresApiKey]` (applied to Troubleshoot/Logs/Settings/Maintenance so they stay gated even if reads are opened). `OPTIONS` exempt. - **#281 S2 — deleted dead bypass surfaces.** `SortController` (`POST media/collections/{id}/items`, dead Blazor residue — SPA uses `PUT /api/collections/{id}/custom-order`) and `AccountController` (`POST account/logout`, dead OIDC). Both were non-`/api` persistent mutations that bypassed the key. - **#284 S6 — CORS opt-in.** `AllowAll` → `Api:CorsAllowedOrigins` exact-origin allowlist (permits `X-Api-Key`/`If-Match`, exposes `ETag`); default no cross-origin (SPA is same-origin). - **#285 S7/S10 — residue.** `gc` GET→POST (spec regenerated); `ForwardedHeaders` trust configurable via `ForwardedHeaders:KnownProxies`/`KnownNetworks` (unconfigured = current trust-all **+ a warning** — flipping to loopback-only would break reverse-proxy M3U/XMLTV URL generation, so the operator names their proxy net); `ScannerController` gains `[LocalhostOnly]` (scanner always calls back over `localhost`; interlocks with restricting ForwardedHeaders trust). `search/all-items` DoS-paging **deferred** (feeds the SPA add-all flow; unauth exposure already closed by read-gating). - **SPA.** Client sends the key on **every** method; keyless `/app/api-key` screen; shell-level 401 banner. (spa-conventions §5e.) ## Verification - **Unit/integration**: ErsatzTV.Tests **1420/1420** green (new: filter fail-closed/read-gating/tier/OPTIONS tests + reflective tier assertions in `ApiControllerSecurityTests`). Web **692/692**, typecheck/lint/build clean, `check:api` no drift. - **Live E2E** (fresh instance): `api.key` written `0600` (256-bit); `GET /api/channels` no-key → **401**, correct key → **200**, wrong key → **401**; `GET /api/settings/ffmpeg` no-key → **401**; **`GET /iptv/channels.m3u` no-key → 200 (Jellyfin unaffected)**; `POST /api/maintenance/gc` no-key → **401** / with key → **200**; old `GET gc` → 404; `OPTIONS /api/channels` not blocked; `/app/api-key` serves keyless; deleted Sort/Account POSTs → no handler. ## Docs (same PR) `docs/api-conventions.md` §9 (auth posture) + §6 (test bullet); `docs/spa-conventions.md` §5e; `docs/decisions.md` (Bundle A entry); regenerated `v1.json` + `endpoint-index.md`. ## Deferred (Phase-2/3, not this bundle) Declarative OpenAPI security scheme + global 401 docs + `/api/v1` versioning (#286/#287/#288); `search/all-items` paging, per-key rate limiting, #265/#269/#172 remainder. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 4 commits 2026-07-12 00:11:28 +02:00
Bundle A SPA slice: the /api surface is now gated behind X-Api-Key on
every request (reads too, RequireKeyForReads defaults true), so a wrong/
missing key 401s everything.

- #282: send X-Api-Key on ALL requests when a key is stored, not only
  mutations (removed the mutatingMethods split in api/client.ts).
- #280: new keyless API Key screen (/app/api-key, System nav) that reads/
  writes only localStorage via auth.ts and never calls /api, so it works
  on a fresh install where every read 401s. Masked key state, Save/Clear,
  points at server-generated /config/api.key.
- 401 UX: client emits one app-wide unauthorized signal (auth.ts
  notify/subscribeUnauthorized); a shell-level UnauthorizedBanner points
  the user at the API Key screen. DRY, no per-screen 401 branches.
- Tests: inverted the GET header assertion (key now sent on reads), added
  no-key and 401-signal client tests, auth signal tests, and screen +
  banner tests. spa-conventions.md §5e documents the new seams.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend of #197 Bundle A (auth posture). Owner decisions: single API key;
Api:RequireKeyForReads defaults true (whole /api surface gated; /iptv streaming
+ guide unaffected — outside the filter's /api scope).

- #280 S1: writes are fail-closed. New IApiKeyProvider resolves the key once
  (Api:WriteKey config, else persisted /config/api.key, else a generated 256-bit
  key written 0600). The empty-key open branch is gone; there is no open mode.
- #282 S3/S5: reads under /api require the key when Api:RequireKeyForReads (default
  true) or the endpoint carries the new [RequiresApiKey]. Applied [RequiresApiKey]
  to Troubleshoot/Logs/Settings/Maintenance so the sensitive tier stays gated even
  if reads are opened. OPTIONS preflight is exempt.
- #281 S2: delete SortController (dead Blazor SortableJS residue; SPA uses PUT
  /api/collections/{id}/custom-order) and AccountController (dead OIDC logout) —
  both non-/api persistent surfaces that bypassed the key.
- #284 S6: replace CORS AllowAll with an opt-in exact-origin allowlist
  (Api:CorsAllowedOrigins; permits X-Api-Key/If-Match, exposes ETag). Default is
  no cross-origin (SPA is same-origin).
- #285 S7/S10: gc GET->POST (spec regenerated); ForwardedHeaders trust configurable
  via ForwardedHeaders:KnownProxies/KnownNetworks (warns when unrestricted);
  ScannerController gains [LocalhostOnly] (scanner always calls back over localhost).

Filter unit tests rewritten for fail-closed + read-gating + tier + OPTIONS;
ApiControllerSecurityTests assert the sensitive tier + scanner-loopback reflectively.
search/all-items paging deferred (SPA add-all coupling) — exposure closed by read-gating.

Refs #197 #280 #281 #282 #284 #285

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(#197): api-conventions §9 auth posture + decisions.md Bundle A entry
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
20d074e7e4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-12 00:11:58 +02:00
docs(#197): record PR #292 in decisions entry
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
98ff9a59f5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-12 00:17:48 +02:00
security(#197): harden ApiKeyProvider persistence + add provider tests (review fixes)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
0fdb2841b7
Cold adversarial review of PR #292 = MERGEABLE-WITH-NITS (no BLOCKER/HIGH). Addresses:
- M1: ApiKeyProvider's never-empty invariant was untested. Add ApiKeyProviderTests
  covering WriteKey precedence, load-existing, empty-file regenerate, generate+persist,
  0600 mode, and still-usable-key-when-persist-fails. ResolveKey extracted to an
  internal seam taking the key path (InternalsVisibleTo ErsatzTV.Tests).
- L2: write-then-chmod race — the key was briefly world-readable. Persist now creates
  the file 0600 atomically via FileStreamOptions.UnixCreateMode (then re-asserts).
- L3: a transient read error on an EXISTING key file silently regenerated + clobbered
  it (invalidating every client key). ResolveKey now rethrows on an unreadable existing
  file (fail loud) and only regenerates when the file is absent or empty.

L4 (LocalhostOnly XFF-spoof under default trust-all) and N5 (length oracle on a
fixed-width key) accepted as documented/cosmetic.

Refs #197 #280

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-12 00:22:29 +02:00
test(#197): cover ApiKeyProvider unreadable-file rethrow + deleted-during-read race
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m42s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m47s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m8s
ee6be81c22
Re-review of the fix commit (MERGEABLE-WITH-NITS) noted the headline L3 rethrow branch
itself had no test. Add a reader seam (internal ResolveKey Func overload) and two tests:
unreadable existing file throws + does not overwrite; a delete race between File.Exists
and the read falls back to generate rather than failing boot.

Refs #197 #280

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy merged commit ee6be81c22 into main 2026-07-12 00:34:45 +02:00
timothy deleted branch bundle-a/197-auth-posture 2026-07-12 00:34:46 +02:00
Sign in to join this conversation.