---
key: security.csp-permissions-policy
title: 2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)
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_Index` hashes the built `wwwroot/app/index.html` when present (else the committed `web/index.html`
  source, 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 inline `style=""` attributes (no CSS-in-JS lib to hash). The **Google Fonts** hosts are required — the
  SPA CSS `@import`s 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 `/openapi` are 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.
