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. The API HTTP and WebSocket origins additionally honour the server-side LUMIO_* twins and a http://localhost:3000/v1 fallback - see the connect-src table below.

connect-src - API HTTP + WebSocket origins

connect-src must allow the origins the browser actually talks to: 'self', the API over HTTP and its WebSocket, the ID/app/overlay origins, the extension supervisor/runtime, and the Sentry ingest. The API HTTP and WebSocket origins are resolved through the same helpers the runtime connects with — getPublicApiOrigin() and getWsOrigin() in apps/web/src/lib/config.ts - so the origin the app opens a socket to and the origin the policy allows can never drift apart:

Origin in connect-srcResolution chain (first match wins)
API HTTPLUMIO_PUBLIC_API_URLNEXT_PUBLIC_API_URLLUMIO_API_URLhttp://localhost:3000/v1
API WebSocketLUMIO_WS_URL (bare host) → derived from the API HTTP origin (httpws, httpswss)
ID appNEXT_PUBLIC_ID_URLLUMIO_ID_URL
This appNEXT_PUBLIC_APP_URLLUMIO_APP_URL
OverlayNEXT_PUBLIC_OVERLAY_URLLUMIO_OVERLAY_URL
Extension supervisor / runtimeNEXT_PUBLIC_SUPERVISOR_URL / NEXT_PUBLIC_EXTENSION_RUNTIME_URL (or LUMIO_* twins)
SentryNEXT_PUBLIC_SENTRY_DSN

One resolver, no guessing (ZAF-1194). The CSP does not re-derive these origins from its own env-var list; it calls the exact getPublicApiOrigin() / getWsOrigin() helpers the runtime opens sockets with. LUMIO_API_URL sits last in the HTTP chain on purpose: in local dev the API is configured through it alone (the documented .env.example sets no NEXT_PUBLIC_API_URL), so it must resolve the origin there - but in staging/prod it is the internal Docker host, and a public name always wins ahead of it, so the internal host never reaches a browser origin or connect-src. Two separate derivation paths for the same URL were the root cause of the origin dropping out of the policy.

Only the origin (scheme+host+port) of these values reaches connect-srcconnect-src matches on origin, so the /v1 API-version prefix that NEXT_PUBLIC_API_URL carries (see below) is stripped by getPublicApiOrigin() and never appears in the policy.

The http://localhost:3000/v1 fallback means a fresh local checkout with no API env var set at all still gets a correct connect-src - matching the WebSocket the app opens by default. In practice the documented .env.example sets LUMIO_API_URL=http://localhost:3000/v1 (the local API config), and because that name is the last link in the HTTP chain above, the origin resolves from the value the app actually connects to rather than from the hardcoded default.

The /v1 prefix lives in the env, not in code. NEXT_PUBLIC_API_URL carries the /v1 API-version segment (in .env.example, in the deployed per-environment images, and in the code fallback). WebSocket URLs are built by wsUrlFromApiUrl() in apps/web/src/lib/config.ts, which swaps the scheme and appends only the WS-specific /ws suffix - so a base already carrying /v1 becomes …/v1/ws and is never doubled to /v1/v1/ws. Every WS hook shares that one helper. The lone exception is the bare LUMIO_WS_URL host (no path), where getWsUrl() appends the full /v1/ws.

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-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-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 fields - violated_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.