# API auth & security posture (#197, #206, #279, #283, #292, #295, #301, #319, #330) Why ErsatzTV's REST/SPA surface is gated the way it is: the #197 cold-review remediation and its bundles, the Blazor-removal auth sign-off, the artwork stored-XSS fix, fail-closed API auth, browser-session auth + CSRF, the side-effecting-GET POST-ification, and the response security headers (CSP/Permissions-Policy/CORP). Rationale relocated from the append-only `docs/decisions.md` at the v26.9.0 consolidation; enforcement/mechanics cross-link to `docs/api-conventions.md` §5/§9 and `docs/spa-conventions.md` §5e. Issue trail: #197 (Phase-0 headers/PR #279; Bundle A fail-closed/PR #292; Bundle C contract-freeze), #206 (Blazor-removal auth posture), #283 (artwork content-type), #295 (PR1 server session auth + PR2 SPA cutover), #301 (side-effecting GETs), #319 (CSP), #330 (CORP). ## Contents - [2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)](#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206) - [2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279)](#2026-07-11--baseline-security-response-headers--phase-0-api-hardening-197-pr-279) - [2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)](#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss) - [2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)](#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292) - [2026-07-12 (#197 Bundle C — contract-freeze honesty)](#2026-07-12-197-bundle-c--contract-freeze-honesty) - [2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)](#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only) - [2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification](#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification) - [2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)](#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline) - [2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)](#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330) --- ## 2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206) Sign-off for the #91 phase (b) removal-gate item #206 ("deleting the last challenged Blazor page leaves only the open SPA"). The actual authorization wiring in `ErsatzTV/Startup.cs` + `ErsatzTV/Pages` was enumerated in code (not assumed) before clearing the gate. **What is gated today** - **OIDC** (`OidcHelper.IsEnabled` — active only when `Authority`/`ClientId`/`ClientSecret` are configured): `AddAuthentication` (cookie default, `oidc` challenge) + `AddAuthorization` `DefaultPolicy = RequireAuthenticatedUser` + **`AddRazorPages(… AuthorizeFolder("/"))`** (Startup.cs:379-385) + `blazor.UseAuthentication()/UseAuthorization()` inside the Blazor `MapWhen` branch (Startup.cs:764-770). `AuthorizeFolder("/")` gates **Razor Pages only**, and the sole user-facing Razor Page is `Pages/_Host.cshtml` — the Blazor Server host (the other `.cshtml`, `Shared/_Favicons.cshtml`, is a cosmetic partial). **So the OIDC challenge protects exactly the Blazor UI and nothing else.** - **`/app` (SPA)** is served by its own `MapWhen(path=/app)` static-file branch (Startup.cs:701-714) with **no authentication/authorization middleware** — open since phase (a) (`/`→`/app`, PR #148). - **`/api/*` controllers** carry no `[Authorize]` (verified: zero attributes in `Controllers/`); the Razor-Pages `AuthorizeFolder`/`DefaultPolicy` never reach them. Their only optional gate is the per-endpoint `ApiKeyAuthorizationFilter` (API-key on mutating JSON endpoints), independent of OIDC/Blazor. - **`/iptv/*`** is gated by `ConditionalIptvAuthorizeFilter` (JWT `JwtOnlyScheme`, active only when `JwtHelper.IsEnabled`) in its own `MapWhen` branch (Startup.cs:797-803) — independent of Blazor. **Posture after Blazor removal.** Removing `Pages/_Host.cshtml`, `AddRazorPages`/`AuthorizeFolder("/")`, `blazor.UseAuthentication/UseAuthorization`, `MapBlazorHub`, and `MapFallbackToPage("/_Host")` deletes the OIDC challenge's **only attachment point** — no user-facing surface remains challenged. **No capability is lost:** every Blazor-served capability already has an open SPA equivalent (the #91 parity effort), and the SPA was already the unauthenticated path since phase (a), so removal exposes nothing a user could not already reach via `/app`. **The one honest caveat (not a regression introduced by removal):** an OIDC-configured operator's *Blazor* admin UI sits behind a login today; after removal there is no login-gated admin UI at all (the SPA admin UI is open). That exposure delta already happened at **phase (a)** (the open SPA became the default admin surface); removal only deletes the now-redundant challenged duplicate. Designing real SPA/API authentication is deliberately deferred to **#197** (cold API security review — a HARD GATE before any remote exposure). **Removal-PR must-not-break (independent gates that survive):** `ConditionalIptvAuthorizeFilter` (`/iptv/*` JWT), `ApiKeyAuthorizationFilter` (mutating `/api/*`), and `JwtHelper` access_token query support. **Leave the OIDC service registrations in place** (conditional on config, inert once no Razor Page consumes them) — ripping OIDC out is a #197 decision, not a removal-PR one. The removal PR removes only the Blazor-attached pieces above; `MapControllers()` + `/docs` (Scalar), currently co-hosted in the Blazor `MapWhen` branch, must survive the surgical reduction. ## 2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279) Phase-0 of the #197 remediation — the posture-**independent** safe subset, shipped ahead of the fail-closed/CORS/versioning posture work tracked in #280–#289. - **Baseline security headers on every response.** New `ErsatzTV/Middleware/SecurityHeadersMiddleware`, registered **first** in the pipeline (before the `/iptv` `MapWhen` branch and `UseCors`), so it covers `/api`, `/iptv`, `/artwork`, static, the SPA fallback, and filter-produced 4xx alike — which is why it's middleware, not an MVC filter. It sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin`. `nosniff` is the standing backstop for the artwork content-type MIME-sniffing risk (#283). **CSP and HSTS are deliberately NOT included here**: CSP must be validated against the ChicoryTV SPA's inline assets, and HSTS is a proxy/TLS-termination decision — both belong to the #197 posture design (#284/roadmap), not this baseline. Headers are set eagerly (not via `Response.OnStarting`); safe today because the pipeline has no `UseExceptionHandler`/`UseStatusCodePages` that would `Response.Clear()` — switch to `OnStarting` if one is ever added. - **Constant-time API-key comparison.** `ApiKeyAuthorizationFilter` compares `X-Api-Key` with `CryptographicOperations.FixedTimeEquals` (over UTF-8 bytes) instead of ordinal `string.Equals`, removing the response-timing oracle on the write key. Accept/reject behavior is otherwise identical. - **Playout pagination clamped.** `GET /api/playouts` and `GET /api/playouts/{id}/items` now clamp `Math.Clamp(pageSize, 1, 100)` + `Math.Max(0, pageNum)` before the query — applying the api-conventions §1 clamp convention the other paged endpoints already follow (these two were passing the raw client value straight to EF `Take()`). The larger #197 posture (fail-closed writes, sensitive-read auth tier, CORS lockdown, `/api/v1` versioning, the OpenAPI security scheme) is decomposed into #280–#289 with the phased roadmap on #197; those PRs will append their own decisions here as they land. ## 2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS) The artwork upload/serve path trusted client-supplied content types at both ends, giving a stored-XSS chain on **unauthenticated** GET sinks: upload `