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.
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)
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>
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>
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 main2026-07-12 00:34:45 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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)
Api:RequireKeyForReadsdefaultstrue— the whole/apisurface requiresX-Api-Key. Verified this does NOT affect Jellyfin/streaming:/iptv/*(playlist/guide/streams/logos) and/artwork/*are outside the filter's/apiscope and keep their own optional access-token; only the SPA-facing management API is gated./config/api.key) is entered on the new keyless API Key screen — an intended effect of the strict default.What changed
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.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).OPTIONSexempt.SortController(POST media/collections/{id}/items, dead Blazor residue — SPA usesPUT /api/collections/{id}/custom-order) andAccountController(POST account/logout, dead OIDC). Both were non-/apipersistent mutations that bypassed the key.AllowAll→Api:CorsAllowedOriginsexact-origin allowlist (permitsX-Api-Key/If-Match, exposesETag); default no cross-origin (SPA is same-origin).gcGET→POST (spec regenerated);ForwardedHeaderstrust configurable viaForwardedHeaders: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);ScannerControllergains[LocalhostOnly](scanner always calls back overlocalhost; interlocks with restricting ForwardedHeaders trust).search/all-itemsDoS-paging deferred (feeds the SPA add-all flow; unauth exposure already closed by read-gating)./app/api-keyscreen; shell-level 401 banner. (spa-conventions §5e.)Verification
ApiControllerSecurityTests). Web 692/692, typecheck/lint/build clean,check:apino drift.api.keywritten0600(256-bit);GET /api/channelsno-key → 401, correct key → 200, wrong key → 401;GET /api/settings/ffmpegno-key → 401;GET /iptv/channels.m3uno-key → 200 (Jellyfin unaffected);POST /api/maintenance/gcno-key → 401 / with key → 200; oldGET gc→ 404;OPTIONS /api/channelsnot blocked;/app/api-keyserves 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); regeneratedv1.json+endpoint-index.md.Deferred (Phase-2/3, not this bundle)
Declarative OpenAPI security scheme + global 401 docs +
/api/v1versioning (#286/#287/#288);search/all-itemspaging, per-key rate limiting, #265/#269/#172 remainder.🤖 Generated with Claude Code