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
| Concern | File |
|---|---|
| Directive builder | apps/web/src/lib/csp.ts (buildContentSecurityPolicy()) |
| Header wiring | apps/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 collector | apps/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 tests | apps/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), plusdata:/blob:.frame-src—player.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-srcmust 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 fields —
violated_directiveand the scheme+host ofblocked-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) atwarn; - forwards the two sanitised fields to the Rust API
(
POST /v1/csp-report-ingest, public) and returns204. The forward is fail-open: a backend failure never changes the204the 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)
- Triage the collected reports through the query — GraphQL
cspViolationReportsor RESTGET /v1/admin/csp-reports(needscsp-reports:read), ordered byreport_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.) - Replace
script-src/style-src'unsafe-inline'with a per-request nonce (needs a request-time header path, e.g.proxy.ts). - Decide the framing policy for overlay/widget embedding before enforcing
frame-ancestors. - Rename the header from
Content-Security-Policy-Report-OnlytoContent-Security-Policy. - Re-tighten
apps/web/content/{en,de}/legal/security.mdxonce 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.