Skip to main content

Web Content-Security-Policy

The webapp (apps/web) sets an application-level Content-Security-Policy on every response as defense-in-depth on top of the CSP already applied at the Cloudflare edge for the production lumio.vision surfaces. The app layer does not trust the edge alone (Zero Trust): if the origin is ever reached without the edge in front of it, the app still ships a policy.

Report-Only first

The policy is currently emitted under the Content-Security-Policy-Report-Only header, not the enforcing Content-Security-Policy header. In Report-Only mode the browser blocks nothing; it only sends a violation report whenever the policy would have blocked a resource. This lets us inventory exactly which sources real traffic needs — across the dashboard, marketing, auth, overlay, popout and widget surfaces — before anything is enforced.

Do not flip the header to the enforcing name until the reports have been triaged and the directives calibrated.

Where it lives

ConcernFile
Directive builderapps/web/src/lib/csp.ts (buildContentSecurityPolicy())
Header wiringapps/web/next.config.ts (async headers(), source: "/:path*")
Report collector (sanitises + forwards)apps/web/src/app/api/csp-report/route.ts
Public-route allowlist for the collectorapps/web/src/proxy.ts (PUBLIC_API_PREFIXES)
API ingest (public, fail-open)apps/api/src/routes/csp_reports.rs (POST /v1/csp-report-ingest)
Persistence (capped aggregate)csp_violation_reports in TimescaleDB (apps/api/tsdb_migrations/20260904000001_create_csp_violation_reports)
Read surface (admin RBAC)GraphQL cspViolationReports / REST GET /v1/admin/csp-reports (csp-reports:read)
Regression testsapps/web/__tests__/csp.test.ts, apps/web/__tests__/csp-report-safe-host.test.ts, apps/api/tests/csp_reports.rs

Directives

buildContentSecurityPolicy() derives the origin-specific directives from the same NEXT_PUBLIC_* env vars the app already uses (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_ID_URL, NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_OVERLAY_URL, NEXT_PUBLIC_SUPERVISOR_URL, NEXT_PUBLIC_EXTENSION_RUNTIME_URL, NEXT_PUBLIC_SENTRY_DSN). Because these are inlined at build time, each per-environment image bakes the origins for its own environment.

Static third-party allowances that are not env-derived:

  • img-src*.giphy.com (Twitch GIFs, see below), the emote CDNs (cdn.7tv.app, cdn.betterttv.net, cdn.frankerfacez.com, static-cdn.jtvnw.net), the platform avatar CDNs (cdn.discordapp.com, lh3.googleusercontent.com, yt3.ggpht.com) and Spotify album art (i.scdn.co), plus data: / blob:.
  • frame-srcplayer.twitch.tv, clips.twitch.tv, www.youtube.com, www.youtube-nocookie.com, open.spotify.com (embeds), plus the extension supervisor/runtime and overlay origins.
  • script-src / style-src'self' 'unsafe-inline'. Next.js injects inline bootstrap/hydration scripts and styled-jsx blocks; until a per-request nonce is wired in, keeping 'unsafe-inline' in the Report-Only phase means the reports surface genuinely-external loads rather than drowning in framework inlines. Replacing 'unsafe-inline' with a nonce is the main task of the enforce phase.
  • media-src — left open ('self' data: blob: https:) because overlay and widget surfaces play user-supplied alert media from arbitrary hosts.

Baseline lock-downs: default-src 'self', object-src 'none', base-uri 'self', frame-ancestors 'self', form-action 'self' <id-origin>.

Hard constraint (ZAF-957): img-src must keep *.giphy.com. Twitch's GIF URLs may not be rewritten or proxied, so a policy that blocks the GIPHY host would break the GIF feature the moment it is enforced. The regression test pins this.

Violation reports

report-uri and report-to both point at /api/csp-report (Reporting-Endpoints: csp="/api/csp-report"). The collector:

  • is public (unauthenticated / cross-origin beacons from marketing, overlay and widget browsers must reach it — see PUBLIC_API_PREFIXES);
  • caps the accepted body (16 KiB) so it can't be used to flood logs;
  • sanitises to two bounded fieldsviolated_directive and the scheme+host of blocked-uri (safeHost()), dropping path, query and fragment so a ?token=lm_… on a widget/popout page — and the path itself, a residual viewer-identity leak — can never be stored or logged;
  • logs a bounded, host-only summary (blocked_host, violated_directive, document_host, disposition) at warn;
  • forwards the two sanitised fields to the Rust API (POST /v1/csp-report-ingest, public) and returns 204. The forward is fail-open: a backend failure never changes the 204 the beacon expects.

The API ingest handler (apps/api/src/routes/csp_reports.rs) is unauthenticated (mirrors abuse-reports; the anonymous rate-limit bucket on CF-Connecting-IP covers it) and re-sanitises server-side (never trust the forwarder): violated_directive is folded to the known CSP directive set (else <other>) and blocked_host to scheme://host / bare scheme: / a CSP keyword (else <other>). It then records the pair into the capped csp_violation_reports TimescaleDB aggregate — distinct (violated_directive, blocked_host) pairs with report_count + first_seen/last_seen, hard-capped at 5000 distinct pairs (new pairs beyond the cap fold into a single <capped> overflow row, so blocked_host — attacker-influenceable on overlay/widget surfaces — cannot exhaust storage) and pruned on a rolling ≤30 day always-on daily job. The whole path is fail-open (a DB or metric failure still returns 204).

Because blocked_host is host-only (no viewer identity), the rows are not personal data, so the retention job is a flat DELETE (contrast audit_events, which must be anonymised, not dropped).

Reading the signal. Operators with the admin-scope csp-reports:read permission query the aggregate through the admin app: GraphQL cspViolationReports(limit, orderBy) (primary) or REST GET /v1/admin/csp-reports?limit=…&order_by=… (twin) — same guard, same fields, same errors. This is the calibration surface; there is no new standing prod-log or shell grant (CIO least-privilege ruling, ZAF-1050).

Ops (optional). The ingest handler also increments csp_violations_total{violated_directive} on the internal /metrics (violated_directive is the only label — bounded; the host dimension is deliberately absent, per the lo-metrics cardinality rule). It gives an at-a-glance breakage rate but is not the calibration path (no host).

Threat note: the public ingest lets an attacker POST fake directive/host pairs, polluting the calibration signal — low severity (a calibration aid, not a security control), bounded by the rate limit, the 5000-pair cap, the ≤30 d retention, and host sanitisation. Documented and accepted (ZAF-1051).

Enforce-flip checklist (later)

  1. Triage the collected reports through the query — GraphQL cspViolationReports or REST GET /v1/admin/csp-reports (needs csp-reports:read), ordered by report_count — to see which (violated_directive, blocked_host) pairs actually fire, and separate the bounded app hosts to allowlist from the arbitrary-content overlay/widget hosts. (No raw prod-log grep — that surface was the whole reason for ZAF-1050.)
  2. Replace script-src/style-src 'unsafe-inline' with a per-request nonce (needs a request-time header path, e.g. proxy.ts).
  3. Decide the framing policy for overlay/widget embedding before enforcing frame-ancestors.
  4. Rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy.
  5. Re-tighten apps/web/content/{en,de}/legal/security.mdx once the app-side claim is actually true.

Parity: if the CSP is later rolled out to apps/admin / apps/id, apply the same Report-Only-first discipline there.