Adds metadata blocks (key/status/since/supersedes/superseded-by + Rule/Signals/Mechanics) to all 26 legacy-unmigrated ## records across optimistic-concurrency.md (7, concurrency.*), api-auth-security.md (9, security.*), release-ci-governance.md (7 remaining legacy records, release.*), and spa-modularization.md (3, spa.*). Heading text and rationale prose are byte-identical; only metadata was inserted, per the key-table assignments. security.session-auth-dual-credential (#295 PR1) gets a one-line cross-reference noting it narrows the OIDC-inert sub-claim of security.blazor-removal-auth-posture (#206), per orchestrator adjudication — both stay status: active with supersedes/superseded-by: none. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
40 KiB
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 — 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 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)
- 2026-07-12 (#197 Bundle C — contract-freeze honesty)
- 2026-07-12 — Browser SPA session auth:
/apiaccepts 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 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)
- 2026-07-13 — Cross-origin resource policy:
same-originon every response (#330)
2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)
key: security.blazor-removal-auth-posture · status: active · since: 2026-07-11 · supersedes: none · superseded-by: none
Rule: Removing the Blazor UI's OIDC-challenged surface exposes nothing a user couldn't already reach via the already-open /app SPA (open since phase (a)); real SPA/API authentication is deliberately deferred to #197, and the removal PR must preserve ConditionalIptvAuthorizeFilter, ApiKeyAuthorizationFilter, and JwtHelper access_token support.
Signals: Blazor removal, auth posture sign-off, OIDC attachment point · paths: ErsatzTV/Startup.cs, ErsatzTV/Pages · issues: #206, #91, #197
Mechanics: ErsatzTV/Startup.cs (Razor Pages/OIDC registration); #91 phase (b) removal PR
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 whenAuthority/ClientId/ClientSecretare configured):AddAuthentication(cookie default,oidcchallenge) +AddAuthorizationDefaultPolicy = RequireAuthenticatedUser+AddRazorPages(… AuthorizeFolder("/"))(Startup.cs:379-385) +blazor.UseAuthentication()/UseAuthorization()inside the BlazorMapWhenbranch (Startup.cs:764-770).AuthorizeFolder("/")gates Razor Pages only, and the sole user-facing Razor Page isPages/_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 ownMapWhen(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 inControllers/); the Razor-PagesAuthorizeFolder/DefaultPolicynever reach them. Their only optional gate is the per-endpointApiKeyAuthorizationFilter(API-key on mutating JSON endpoints), independent of OIDC/Blazor./iptv/*is gated byConditionalIptvAuthorizeFilter(JWTJwtOnlyScheme, active only whenJwtHelper.IsEnabled) in its ownMapWhenbranch (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)
key: security.baseline-response-headers · status: active · since: 2026-07-11 · supersedes: none · superseded-by: none
Rule: SecurityHeadersMiddleware, registered first in the pipeline, sets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin on every response (CSP/HSTS deliberately deferred); API-key comparison is constant-time and playout pagination is clamped.
Signals: security headers, nosniff, Phase-0 hardening, constant-time comparison · paths: ErsatzTV/Middleware/SecurityHeadersMiddleware · issues: #197, #279, #283
Mechanics: ErsatzTV/Middleware/SecurityHeadersMiddleware
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/iptvMapWhenbranch andUseCors), 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 setsX-Content-Type-Options: nosniff,X-Frame-Options: DENY, andReferrer-Policy: strict-origin-when-cross-origin.nosniffis 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 viaResponse.OnStarting); safe today because the pipeline has noUseExceptionHandler/UseStatusCodePagesthat wouldResponse.Clear()— switch toOnStartingif one is ever added. - Constant-time API-key comparison.
ApiKeyAuthorizationFiltercomparesX-Api-KeywithCryptographicOperations.FixedTimeEquals(over UTF-8 bytes) instead of ordinalstring.Equals, removing the response-timing oracle on the write key. Accept/reject behavior is otherwise identical. - Playout pagination clamped.
GET /api/playoutsandGET /api/playouts/{id}/itemsnow clampMath.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 EFTake()).
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)
key: security.artwork-content-type-sniff · status: active · since: 2026-07-12 · supersedes: none · superseded-by: none
Rule: Artwork content type is always derived from the stored bytes (never the client-declared value or a ?contentType= query param) at both upload and serve, clamped to an image allow-list, closing the unauthenticated stored-XSS chain; Kestrel MaxRequestBodySize bounds upload DoS.
Signals: stored XSS, content-type sniffing, artwork upload/serve · paths: ErsatzTV.Core/Images/ImageContentTypes.DetectContentType, GetCachedImagePathHandler · issues: #283, S4, S9
Mechanics: ErsatzTV.Core/Images/ImageContentTypes
The artwork upload/serve path trusted client-supplied content types at both ends, giving a stored-XSS
chain on unauthenticated GET sinks: upload <script> bytes declared image/png →
GET /iptv/logos/{hash}?contentType=text/html served them as HTML in the ErsatzTV origin. The #279
nosniff header is not a fix here — the server was explicitly declaring text/html, which the browser
honors regardless of nosniff. The trust was the bug; the fix removes it at both ends.
- Upload derives the content type from the bytes, never the declared value.
UploadArtworkHandlerbuffers the (size-bounded) upload and callsErsatzTV.Core/Images/ImageContentTypes.DetectContentType, which uses SkiaSharp'sSKCodecto identify the format from the image header only — pixels are not decoded, so this can't be turned into a decompression-bomb vector. A payload that isn't one of the accepted raster formats (png/jpeg/gif/webp) is rejected 422; the declaredContent-Typeis no longer read at all (the field was dropped from theUploadArtworkcommand). - Serve sniffs the stored file; the
?contentType=reflection is gone.GetCachedImagePathno longer carries aContentType, andGetImage(/iptv/logos) /GetWatermark(/artwork/watermarks) dropped their[FromQuery] contentTypebinding.GetCachedImagePathHandleralways derives the MIME type from the file (MimeTypes.GetMimeTypeFromFile) and clamps it to the image allow-list (ImageContentTypes.IsAccepted), servingapplication/octet-streamfor anything else — so a file whose bytes are not an accepted image (a legacy cache entry poisoned before the upload sniff landed, or a hypothetical polyglot) is a non-renderable download, never HTML/script. The removal is structural — there is no longer any request path that lets a client choose the servedContent-Type.ArtworkContentTypeModel.UrlWithContentTypenow returns the bare path, and the SPA watermark/logo previews no longer append the query. - Defense-in-depth on the persisted JSON DTOs. The
{path, contentType}bodies (channel logo, watermark) run their content type throughArtworkContentTypeModel.Sanitized(), which blanks anything outside the image allow-list before it is stored — so a stale/hostile value can't be reflected by any future code path even though the serve route already ignores it. - S9 upload-size DoS. Kestrel
Limits.MaxRequestBodySizeis now set fromETV_MAXIMUM_UPLOAD_MB, so an oversized body is rejected as it is read rather than only after the controller's post-bindingfile.Lengthcheck (kept as the friendly-error backstop). This is a global bound; the app has no other large inbound body (streaming is outbound GET).
ImageContentTypes is the single source of truth for the accepted image types (the allow-list previously
duplicated in UploadArtworkHandler). Both serve sinks are [ApiExplorerSettings(IgnoreApi = true)], so
none of this changes the OpenAPI document.
2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)
key: security.fail-closed-api-auth · 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:WriteKeyif set, else a key persisted atFileSystemLayout.ApiKeyPath(/config/api.key,0600, path logged not value), else a generated 256-bit hex key. Every mutating/apirequest now requiresX-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 toTroubleshoot/Logs/Settings/Maintenance, so that tier stays gated even if an operator opts reads open.OPTIONSpreflight is exempt (CORS middleware owns it).ApiControllerSecurityTestsasserts the tier reflectively. - Delete dead non-
/apimutation surfaces (#281, S2).SortController(POST media/collections/{id}/items, dead Blazor SortableJS residue — the SPA usesPUT /api/collections/{id}/custom-order) andAccountController(POST account/logout, dead OIDC) bypassed the key because they sat outside/api. Removed rather than guarded. - CORS opt-in (#284, S6).
AllowAnyOrigin/Method/Headeris replaced by theApiCorspolicy: an exact-origin allowlist fromApi:CorsAllowedOrigins(semicolon list) that permitsX-Api-Key/If-Matchand exposesETag; 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).ForwardedHeaderstrust is configurable viaForwardedHeaders: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).ScannerControllergains[LocalhostOnly](the scanner always calls back overhttp://localhost:{UiPort}/api/scan/...), which is only spoof-resistant once ForwardedHeaders trust is restricted — the two interlock.search/all-itemsDoS-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.
2026-07-12 (#197 Bundle C — contract-freeze honesty)
key: security.contract-freeze-honesty · status: active · since: 2026-07-12 · supersedes: none · superseded-by: none
Rule: The OpenAPI doc's declared security/401 scheme is generated from the same ApiKeyAuthorizationFilter.EndpointRequiresKey predicate the runtime enforces (so declared auth can't drift from enforced auth), every /api/* action returns a ResponseModel (no raw Application VMs), and Channel REST resources are keyed by immutable Id, never mutable Number.
Signals: OpenAPI contract honesty, ResponseModel wrapping, Id vs Number key · paths: OpenApiContractHonestyTests, ErsatzTV.Core/Api · issues: #197, #287, #288
Mechanics: OpenApiContractHonestyTests; docs/api-conventions.md
#287 — OpenAPI contract honesty by construction. The "v1" document now emits the ApiKey security
scheme plus per-operation security/401 derived from the same
ApiKeyAuthorizationFilter.EndpointRequiresKey predicate the runtime filter enforces, so declared auth
can never drift from enforced auth. Every operation also gets a synthesized stable operationId (the
framework only assigned one when Name= was set — ~90 were missing), and body/param-binding operations
get the documented 400 ValidationProblemDetails they actually return. DayOfWeek is now a string enum
in the schema (added to Startup.UseStringEnumSchemas), removing the SPA's WithDayNames wart. Pinned by
in-process document generation in tests (OpenApiContractHonestyTests) rather than the committed v1.json.
#288 — Wrap the last raw ViewModels; reverse the §7a "intentional version leak." Minted
MediaCollectionResponseModel, ProgramScheduleResponseModel, and ChannelDetailResponseModel (all
#nullable enable) and routed CollectionController / ScheduleController / SmartCollectionController /
ResolutionController.GetResolutionByName / the channel detail GET+writes through ResponseModels, so no
/api/* action returns an Application VM. This reverses the earlier §7a judgment that a ResponseModel
"purely to hide one field was disproportionate": Version is now header-only (ETag) on every aggregate
body — confirmed safe by grepping web/src (the SPA reads version from the ETag header, never the
response body). ChannelDetailResponseModel is the full editable field set the channel editor needs
(distinct from the lean list ChannelResponseModel; drops only the derived webEncodedName). Also flipped
#nullable enable onto the remaining 24 lagging ErsatzTV.Core/Api/ files for schema honesty, and added
pageNum paging to GET /api/search.
Channel REST resources are keyed by database Id, never by Number. Channel.Number is user-mutable
(editable on update, bulk-renumbered via /api/channels/bulk/renumber, transiently invalid mid-renumber),
so the immutable int PK is the canonical key for all /api/channels/* single-item routes, sub-resources
(including playout/reset, re-keyed from {channelNumber} to {id:int} in Bundle C), and Location
headers. Number remains the identity on broadcast surfaces only (IPTV/M3U/XMLTV), a separate contract. A
number-based lookup endpoint may be added additively later; UniqueId (Guid) stays out of the REST
contract absent a federation requirement.
2026-07-12 — Browser SPA session auth: /api accepts session OR machine key (#295 PR1, server-only)
key: security.session-auth-dual-credential · status: active · since: 2026-07-12 · supersedes: none · superseded-by: none
Rule: ApiAuthorizationFilter accepts a request when a valid X-Api-Key matches OR the principal is an authenticated session (cookie ctv-session, HttpOnly/SameSite=Lax); session-authenticated mutations require the presence-only X-CSRF header or are rejected 403. This narrows the OIDC-inert sub-claim of security.blazor-removal-auth-posture (#206) — the rest of that record's auth-surface enumeration still holds.
Signals: session auth, dual credential, CSRF, OIDC revival, local admin · paths: ApiAuthorizationFilter, ErsatzTV/Startup.cs · issues: #295, #206, #197
Mechanics: docs/spa-conventions.md §5e (PR2); RootWriterForceVersionTests-adjacent auth tests
Implements the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Supersedes the #206
"OIDC wiring stays inert until #197" note: the retained OIDC service registration is now revived, and a
cookie session becomes a first-class /api credential alongside the machine X-Api-Key. PR1 is
server-only and backward compatible — the SPA keeps sending its stored key; the SPA login flow, the
ApiKeyScreen→machine-key repurpose, and spa-conventions §5e land in PR2.
One gate, evolved (not [Authorize]-per-controller). ApiKeyAuthorizationFilter → ApiAuthorizationFilter,
same fail-closed-by-omission logic (a forgotten [Authorize] fails open — the #280 failure mode — so the
global filter stays the gate). It now accepts a request when a valid X-Api-Key matches OR the principal
is an authenticated session; the "does this endpoint need auth?" decision is still the single shared
EndpointRequiresKey(...) predicate (also drives OpenAPI, so the spec can't drift). Attributes renamed to
match the widened meaning: [RequiresApiKey]→[RequiresAuthentication], [SkipApiKeyAuthorization]→
[SkipApiAuthorization]. IApiKeyProvider, the X-Api-Key header, and Api:WriteKey/Api:RequireKeyForReads
are unchanged — machine/key behavior is byte-identical (verified: no OpenAPI drift, existing filter tests
still green).
CSRF (session only). The machine key is CSRF-immune (a browser can't set a custom header cross-origin
without a credentialed CORS grant we never issue). A cookie session is not: a session-authenticated mutation
must carry the X-CSRF header (presence-only — a custom header forces a CORS preflight a cross-site page can't
satisfy) or is rejected 403. Reinforced by SameSite=Lax + CORS without AllowCredentials (cross-origin
cookie auth is impossible by design). No antiforgery-token machinery.
Cookie ctv-session. Always registered (local login works with no IdP); OIDC handler added only when
OIDC:* is configured. HttpOnly, SameSite=Lax, SecurePolicy=SameAsRequest (so a plain-HTTP LAN isn't
bricked), 14-day sliding. /api XHR gets 401/403, not a redirect (OnRedirectToLogin/AccessDenied).
The UseAuthentication/UseAuthorization middleware — deleted with Blazor in #91b — is revived in the
legacy MapWhen branch only (hosts /api + OIDC /callback + /docs; /iptv and /app untouched).
Local store = ConfigElement rows, single admin, NO migration (owner ruling F2):
AuthLocalAdminUsername, AuthLocalAdminPasswordHash (ASP.NET PasswordHasher, PBKDF2, via
Microsoft.Extensions.Identity.Core), AuthSecurityStamp. A password change rotates the stamp; the cookie
OnValidatePrincipal (CookieSecurityStampValidator) compares the claim to the stored stamp and rejects a
stale session (revocation). OIDC sessions carry an etv:auth_method=oidc claim and skip the stamp check
(governed by the IdP).
Fail-closed out of the box + recovery. An unconfigured instance keeps /api gated (the key still works);
first-run is a setup-claim (POST /api/auth/setup, first-claim-wins, only valid while unconfigured —
owner ruling F1). Recovery without the browser: Auth:LocalAdmin:Password env seed (LocalAdminSeedService,
overwrites + rotates the stamp on startup) or the machine key. Login hardening: per-IP rate limit
([EnableRateLimiting("auth")], 10 / 5 min) on login/setup/password, dummy-hash verify on unknown/unconfigured
user (no enumeration).
Authelia = app-owned OIDC session; never trust proxy identity headers (owner ruling F3): the container is
LAN-reachable bypassing the proxy, so Remote-User/Remote-Email header trust is spoofable. OIDC→Authelia
gives SSO without a double login. ForwardedHeaders behaviour is kept unchanged from #285 (trust any peer
with a warning; restrict via KnownProxies/:KnownNetworks). A stricter "ignore X-Forwarded-* unless a proxy
is configured" default was implemented and then reverted after review (cold fork M1): the forwarded
scheme/host feed /iptv M3U/XMLTV/HLS absolute-URL generation (Request.Scheme in GetChannelGuideHandler/
IptvController), so ignoring them would regress stream URLs to http/internal-host for a proxied deployment
that hasn't set KnownProxies. Deployment coordination: operators behind a proxy should set
ForwardedHeaders:KnownProxies/:KnownNetworks — it gives the login rate limiter an unspoofable client IP and
marks the session cookie Secure behind TLS. The residual (a direct LAN peer can spoof X-Forwarded-For to
evade the per-IP login limit when unrestricted) is accepted defense-in-depth loss, mitigated by PBKDF2 +
no-enumeration.
Review hardening (fork + independent Codex pass, folded into PR1). Codex caught concurrency defects the
fork missed — folded in: (a) atomic first-claim-wins — setup writes the three credential rows in one
transaction guarded by the unique ConfigElement.Key index (a lost race → DbUpdateException → 409), so a
concurrent claim can't produce a mixed-state credential; (b) consistent login snapshot — login reads the
hash + stamp in one query and no longer rehashes-on-verify, so a login racing a password change can't capture a
newer stamp than the hash it verified (a concurrent change either fails the old password or leaves the issued
cookie carrying the pre-change stamp → revoked next request); (c) env-seed waits on
SystemStartup.WaitForDatabase (the migrator is a BackgroundService, so registration order alone didn't
guarantee the schema existed) — moved to Services/RunOnce/. Also: logout + password require X-CSRF
(the [SkipApiAuthorization] auth surface isn't covered by the filter's CSRF check → forced-logout CSRF), and
input length caps on username/password. Logout rotates the security stamp when called from a local session
(E2E-caught: SignOutAsync alone only clears the client cookie, leaving the stateless encrypted ticket
replayable server-side) — so signing out actually ends the session server-side; for the single admin this
revokes all local sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated
caller can't force-revoke the admin. Deferred with a tracked gate: side-effecting [RequiresAuthentication]
GETs (troubleshoot playback/archive) aren't CSRF-covered — #301, gating PR2 (latent in PR1: the SPA still
uses the machine key). OIDC-session revocation lever (no local stamp) noted for PR3 operator docs.
A fix-commit re-review (Codex, #242 discipline) then confirmed the above resolved and caught a second round:
(a) HIGH — env-seed vs. setup race: an attacker could claim admin in the startup window before
LocalAdminSeedService runs, and the seed's insert would then be swallowed (attacker's credential persists,
defeating the env recovery path). Fixed structurally: the setup-claim endpoint is closed whenever
Auth:LocalAdmin:Password is configured — the env seed owns the credential, so there is no claim to race
(this also strengthens the setup-claim TOFU posture: an operator on an untrusted network sets the env password
and browser setup is disabled). (b) LOW: a concurrent setup race-loser now returns 409 (not 422), and
ClaimLocalAdmin's DbUpdateException catch re-checks existence and rethrows genuine/transient DB errors
rather than masking them as "already configured". (c) MEDIUM — accepted: two simultaneous authenticated
password changes are a non-serializable lost-update (last-write-wins; the loser's cookie may be immediately
revoked). Accepted for a single-admin system: it needs two concurrent authenticated sessions both submitting
the correct current password at the same instant, and the outcome is self-healing (re-login). Adding EF
optimistic concurrency to the credential rows is disproportionate here.
OpenAPI = ApiKey-only; /api/auth/* excluded (owner ruling F4): the spec's audience is machine/MCP
clients, and a browser-interactive cookie login isn't something a generated client drives, so the cookie path
is an additional accepted credential the doc needn't express. AuthController is [ApiExplorerSettings(IgnoreApi = true)]. Verified: no v1.json/v1.d.ts/endpoint-index drift from this PR.
Phasing. PR1 = this (server only, no migration). PR2 = SPA (drop the key header for browser calls + add
X-CSRF, AuthContext + boot gate, login/setup screens, ApiKeyScreen→machine-key management, E2E,
spa-conventions §5e). PR3 = key rotation + operator docs (Authelia client + env reference). Rollout: PR1→PR2
same release, then a manual Authelia round-trip checklist before the prod pin bump.
2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification
key: security.session-cutover-postify · status: active · since: 2026-07-12 · supersedes: none · superseded-by: none
Rule: The browser SPA authenticates cookie-only (no more X-Api-Key from web/); the machine key is repurposed to external/MCP-only via GET /api/auth/machine-key; every side-effecting GET/HEAD under /api is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under /api).
Signals: SPA cookie-only cutover, CSRF POST-ification, AuthGate boot flow · paths: web/src/api/client.ts, web/src/AuthGate.tsx · issues: #295, #301, #197
Mechanics: docs/spa-conventions.md §5e; docs/api-conventions.md §9; docs/e2e-local.md
PR1 shipped the server side (previous entry): /api accepts a session cookie OR the machine X-Api-Key, with
X-CSRF required on session-authenticated mutations. PR2 is the SPA cutover — the browser now authenticates
with the session only — plus #301 (a session-cookie CSRF hole in side-effecting GETs).
Browser is cookie-only; the machine key is external/MCP-only. web/src/api/client.ts no longer attaches
X-Api-Key; it relies on the same-origin session cookie and sets X-Csrf: '1' on every mutating verb centrally.
The former "paste your key" ApiKeyScreen is repurposed to machine-key management: it reads the server key
from the new GET /api/auth/machine-key (session-gated; masked with Reveal + Copy) so an operator can hand it to
MCP / external REST clients — the browser itself never sends it again. Why: one credential per audience (the
ratified #295 model); leaving a browser key path alive would keep a CSRF-immune bypass around and defeat the
point.
Boot gate, not a route (web/src/AuthGate.tsx, wrapping <App/> in main.tsx): on load it calls the public
GET /api/auth/config then GET /api/auth/session and renders Setup (first-run local-admin claim) / Login
(local form + an OIDC "Sign in with SSO" button when oidcEnabled) / the app. Login and Setup mint no URL —
the gate renders them at whatever /app/* path was requested, so a deep link survives login for free and no
blazor-route-parity.md/domain-model.md route rows are added. It publishes AuthContext
({ username, method, signOut, requireLogin }); the 401 signal (notifyUnauthorized) now drives re-login via a
passive shell banner (never yanks a dirty draft — it consults the navigation guard first). Auth flows that expect
a 401 inline (login, change-password) pass suppressUnauthorizedSignal.
#301 — POST-ify, don't gate-the-GET. A side-effecting GET is a CSRF vector once a SameSite=Lax cookie is a
normal credential (it rides a cross-site top-level navigation). The three offenders became mutating verbs so the
existing filter CSRF gate covers them with zero new machinery: GET /api/troubleshoot/playback.m3u8 →
POST /api/troubleshoot/playback/start returning 200 { url } (the open /iptv manifest the player then
loads — so hls.js/native-HLS needs no header injection, strictly better than X-CSRF-on-GET); the archive and
sample GETs → POST (SPA downloads them via a fetch-blob helper, never window.open). Removing the HEAD
variants also fixed a latent bug: a HEAD opened the DeleteOnClose stream and destroyed the artifact. Standing
rule added to api-conventions.md §9: never add a side-effecting GET/HEAD under /api.
Machine-key GET discloses the key to any authenticated session — deliberate: the session principal is the
single admin (local or OIDC), same-origin policy blocks a cross-site page from reading the response body, and it
is how the "copy the key for MCP" UX works without a rotation endpoint (rotation is a later PR). Accepted
residual (OIDC logout): POST /api/auth/logout ends the app cookie but not the IdP session, so an OIDC user
who clicks "Sign out" then "Sign in with SSO" returns without re-entering credentials — a returnUrl/RP-initiated
logout is a future nicety. Docs: spa-conventions.md §5e (SPA seams), api-conventions.md §9, e2e-local.md
(browser setup/login flow). Refs #295 #301 #197.
2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)
key: security.csp-permissions-policy · status: active · since: 2026-07-12 · supersedes: none · superseded-by: none
Rule: SecurityHeadersMiddleware sends an enforcing (not report-only) Content-Security-Policy (no unsafe-inline/unsafe-eval; the one inline theme-bootstrap script allow-listed by hash) and a deny-all Permissions-Policy on the SPA//api//artwork//iptv; /docs and /openapi keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap.
Signals: enforcing CSP, script-src hash allowlist, Permissions-Policy · paths: ErsatzTV/Middleware/SecurityHeadersMiddleware, SecurityHeadersMiddlewareTests · issues: #319, #314, #197, #279
Mechanics: SecurityHeadersMiddlewareTests.Csp_Script_Hash_Should_Match_The_Spa_Index
Completes the CSP that the #279 baseline-headers entry deferred ("CSP must be validated against the ChicoryTV
SPA's inline assets"). Surfaced by the #314 out-of-ecosystem ZAP baseline (missing CSP/Permissions-Policy WARNs);
a #197 exit item. SecurityHeadersMiddleware now also sets Permissions-Policy (deny-all for
camera/microphone/geolocation/payment/usb) and an enforcing Content-Security-Policy.
- Enforce, not report-only. Report-only was the issue's acceptable fallback, but the SPA's asset graph is
small and fully knowable, so we ship an enforcing policy (report-only leaves the ZAP WARN and provides no real
protection). The policy:
default-src 'self';script-src 'self' '<sha256 of the inline theme-bootstrap script>'(no'unsafe-inline'/'unsafe-eval'— the real XSS win);style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;img-src 'self' data: blob:;font-src 'self' data: https://fonts.gstatic.com;connect-src 'self';object-src 'none';base-uri 'self';frame-ancestors 'none';form-action 'self'. - Why each relaxation. The SPA is a static file, so a per-response nonce is impossible → the one inline
theme-bootstrap
<script>is allow-listed by hash;SecurityHeadersMiddlewareTests.Csp_Script_Hash_Should_ Match_The_Spa_Indexhashes the builtwwwroot/app/index.htmlwhen present (else the committedweb/index.htmlsource, since the built artifact is gitignored/absent in CI — Vite copies the inline script verbatim) and fails if it drifts from the middleware constant.style-src 'unsafe-inline'covers React's inlinestyle=""attributes (no CSS-in-JS lib to hash). The Google Fonts hosts are required — the SPA CSS@imports the Geist web font (caught by live-E2E, which the static grep missed); self-hosting the font to drop the Google dependency is a follow-on hardening, not this issue.img-src data: blob:covers favicon/generated-image data URIs and object-URL upload previews. - Scoped:
/docs(Scalar) and/openapiare excluded. The Scalar API-reference UI relies on inline bootstrap scripts/styles a strict CSP would break; it keeps the baseline headers (nosniff/frame/referrer) but no CSP. Hardening that admin surface (self-hosted Scalar or a Scalar-tuned CSP) is a #197 follow-up. Everything else — SPA,/api,/artwork,/iptv— gets the CSP (non-HTML responses simply never exercise the script/style directives). Verified by live-E2E (SPA renders clean, zero CSP violations) + curl (CSP present on/app//api, absent on/docs//openapi). HSTS remains out (proxy/TLS decision). Refs #319 #314 #197.
2026-07-13 — Cross-origin resource policy: same-origin on every response (#330)
key: security.corp-same-origin · status: active · since: 2026-07-13 · supersedes: none · superseded-by: none
Rule: SecurityHeadersMiddleware sends Cross-Origin-Resource-Policy: same-origin on every response including /docs//openapi, blocking cross-origin no-cors embedding without affecting allowed CORS-mode fetches or server-side Jellyfin /iptv/* requests.
Signals: CORP, same-origin, cross-origin embedding · paths: ErsatzTV/Middleware/SecurityHeadersMiddleware · issues: #330, #319, #314
Mechanics: ErsatzTV/Middleware/SecurityHeadersMiddleware
The authenticated #314 ZAP scan found that ErsatzTV's baseline response posture omitted
Cross-Origin-Resource-Policy. SecurityHeadersMiddleware now sends
Cross-Origin-Resource-Policy: same-origin on every response, including /docs and /openapi.
Those two paths remain exempt only from the strict CSP that would break Scalar's inline bootstrap;
CORP has no equivalent rendering conflict and belongs with the middleware's path-independent baseline
headers.
same-origin requires the browser request and response to share the exact scheme, host, and port. It
blocks cross-origin no-cors loads, so direct browser embedding of ErsatzTV artwork or media from an
alternate origin is deliberately unsupported. It does not reject an allowed CORS-mode API fetch, so the
explicit Api:CorsAllowedOrigins machine-client path continues to work. It is also not enforced by
server-side HTTP clients, so Jellyfin's /iptv/* requests are unaffected; same-origin SPA artwork and
IPTV requests remain allowed. This is defense in depth for browser embedding and does not replace CORS
or authentication. Refs #330 #319 #314.