--- key: security.fail-closed-api-auth title: '2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)' status: active since: '2026-07-12' supersedes: none superseded-by: none rule: Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. signals: 'fail-closed auth, sensitive-read tier, CORS allowlist, single API key · paths: `IApiKeyProvider`, `ErsatzTV/Services/ApiKeyProvider.cs`, `FileSystemLayout.ApiKeyPath` · issues: #197, #280, #281, #282, #284, #285' mechanics: '`docs/api-conventions.md` §5; `ApiControllerSecurityTests`' --- Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure. Owner decisions (confirmed this session): **single API key** (no read/write split), and **`Api:RequireKeyForReads` defaults `true`** (the whole `/api` surface requires the key). 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 management API the SPA talks to is gated. - **Fail-closed writes (#280, S1).** The empty-key "open" branch is deleted; there is no open mode. New `IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`, singleton, resolved once at startup) yields a never-empty key: `Api:WriteKey` if set, else a key persisted at `FileSystemLayout.ApiKeyPath` (`/config/api.key`, `0600`, path logged not value), else a generated 256-bit hex key. Every mutating `/api` request now requires `X-Api-Key`. - **Sensitive-read tier (#282, S3/S5).** Reads are gated by `Api:RequireKeyForReads` (default true) OR a new `[RequiresApiKey]` marker (mirror of `[SkipApiKeyAuthorization]`) applied to `Troubleshoot`/`Logs`/`Settings`/`Maintenance`, so that tier stays gated even if an operator opts reads open. `OPTIONS` preflight is exempt (CORS middleware owns it). `ApiControllerSecurityTests` asserts the tier reflectively. - **Delete dead non-`/api` mutation surfaces (#281, S2).** `SortController` (`POST media/collections/{id}/items`, dead Blazor SortableJS residue — the SPA uses `PUT /api/collections/{id}/custom-order`) and `AccountController` (`POST account/logout`, dead OIDC) bypassed the key because they sat outside `/api`. Removed rather than guarded. - **CORS opt-in (#284, S6).** `AllowAnyOrigin/Method/Header` is replaced by the `ApiCors` policy: an exact-origin allowlist from `Api:CorsAllowedOrigins` (semicolon list) that permits `X-Api-Key`/`If-Match` and exposes `ETag`; with no origins configured there is no cross-origin access (the SPA is same-origin). - **ForwardedHeaders trust + scanner loopback (#285, S7/S10).** `GET /api/maintenance/gc` → `POST` (crawler-triggerable GC; spec regenerated). `ForwardedHeaders` trust is configurable via `ForwardedHeaders:KnownProxies`/`KnownNetworks` — **unconfigured preserves the current trust-all behavior but logs a warning** (flipping the default to loopback-only would break reverse-proxy scheme/host detection and thus M3U/XMLTV absolute URLs — the operator must name their proxy network). `ScannerController` gains `[LocalhostOnly]` (the scanner always calls back over `http://localhost:{UiPort}/api/scan/...`), which is only spoof-resistant once ForwardedHeaders trust is restricted — the two interlock. `search/all-items` DoS-paging is **deferred** (it feeds the SPA "add all" flow and needs coordinated pagination; the unauth exposure is already closed by read-gating). - **SPA (`web/`).** The client sends the stored key (`ctv-api-key`) on **every** method (not just mutations); a new keyless **API Key** screen (`/app/api-key`) lets the user paste the generated key, and a shell-level banner points there on any 401. See spa-conventions §5e. **First-run/upgrade UX:** with reads gated by default, the SPA shows no data until the key (from `/config/api.key`) is entered — an intended consequence of the strict default. Phase-2 (contract freeze) — the declarative OpenAPI security scheme, global 401 docs, and `/api/v1` versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 remainder, `search/all-items` paging, per-key rate limiting.