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/src/proxy.ts (withCspHeaders(), request time - every response branch)
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
Deployed-header smoke gatescripts/check-deployed-csp.mjs, .github/workflows/deployed-csp-check.yml

Static SVGs under /passkey-providers/​

The proxy's config.matcher skips static .svg files, so the request-time policy above never reaches them. The passkey provider logos come from a community-maintained list, and a logo opened as a top-level document would otherwise run with no policy on our origin. apps/web/next.config.ts headers() therefore sets an enforcing policy and X-Content-Type-Options: nosniff on /passkey-providers/:path*:

Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox

This is the same policy the API sends for SVG extension uploads. style-src 'unsafe-inline' lets the logos that use <style> render when opened directly; <img> rendering is unaffected. The values are static, so freezing them at next build is harmless. apps/web/__tests__/next-config-headers.test.ts guards the entry.

Directives​

proxy.ts calls buildContentSecurityPolicy() on every request, so the origin-specific directives come from the container's runtime env, not from the build. NEXT_PUBLIC_* names are inlined at next build and the release images are built without build args, so in a deployed container only the server-side LUMIO_* names (LUMIO_PUBLIC_API_URL, LUMIO_WS_URL, LUMIO_ID_URL, LUMIO_APP_URL, LUMIO_OVERLAY_URL, LUMIO_SUPERVISOR_URL) actually drive the policy. The API HTTP and WebSocket origins fall back to http://localhost:3000/v1 - 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, 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_URL → NEXT_PUBLIC_API_URL → LUMIO_API_URL → http://localhost:3000/v1
API WebSocketLUMIO_WS_URL (bare host) → derived from the API HTTP origin (http→ws, https→wss)
ID appNEXT_PUBLIC_ID_URL → LUMIO_ID_URL
This appNEXT_PUBLIC_APP_URL → LUMIO_APP_URL
OverlayNEXT_PUBLIC_OVERLAY_URL → LUMIO_OVERLAY_URL
Extension supervisorLUMIO_SUPERVISOR_URL → NEXT_PUBLIC_SUPERVISOR_URL (getConfiguredSupervisorUrl(), the same chain the layouts hand to the framing components; unset adds no source)
SentryLUMIO_PUBLIC_SENTRY_DSN → NEXT_PUBLIC_SENTRY_DSN (getBrowserSentryConfig(), the same getter the root layout hands to the browser SDK; unset adds no source)

Sentry and analytics. The browser Sentry SDK gets its DSN from the same request-time getter as the policy, so the two always agree: a container without LUMIO_PUBLIC_SENTRY_DSN has an inert browser SDK (no ingest requests) and no Sentry origin in connect-src; a container with it gets both. The server-only SENTRY_DSN is used by server/edge Sentry and never needs a browser allowance. The webapp mounts no analytics script, so connect-src and script-src carry no analytics host.

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-src — connect-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), Twitch cheermotes (d3aqoihi2n8ty8.cloudfront.net), Kick (files.kick.com, ext.cdn.kick.com), the platform avatar CDNs (cdn.discordapp.com, lh3.googleusercontent.com, yt3.ggpht.com for YouTube avatars and member badges, yt4.ggpht.com for YouTube chat author photos), Spotify album art (i.scdn.co), the test-event avatars and album art (api.dicebear.com, from apps/web/src/lib/test-events.ts), plus data: / blob:. i.ytimg.com (YouTube video thumbnails) is not allowed: no code path in the webapp renders a video thumbnail.
  • frame-src - the Cloudflare Turnstile challenge origin (challenges.cloudflare.com) plus the extension supervisor and overlay origins. No Twitch, YouTube or Spotify player host is allowed: the webapp frames no player and only links to those sites. No extension runtime origin appears in the policy: every extension runs on its own runtime origin, and that frame is nested inside the supervisor, so the supervisor's own frame-src governs it. The webapp never frames, fetches or connects to a runtime origin - it fetches the bundle through its own proxy and talks to the extension only through postMessage to the supervisor.
  • script-src - 'self' 'unsafe-inline'. Next.js injects inline bootstrap/hydration scripts; keeping 'unsafe-inline' in the Report-Only phase means the reports surface genuinely-external loads rather than drowning in framework inlines. It does not survive the flip: the enforcing policy needs a per-request nonce plus 'strict-dynamic' emitted from proxy.ts, and no 'unsafe-inline'.
  • style-src - 'self' 'unsafe-inline' plus the API origin, which serves the self-hosted font stylesheets. 'unsafe-inline' here is an accepted risk (see below) and stays when the policy enforces.
  • font-src - 'self' data: plus the API origin. Web fonts are self-hosted through the API font proxy and never loaded from Google Fonts directly (see below).
  • media-src - left open ('self' data: blob: https:) because overlay and widget surfaces play user-supplied alert media from arbitrary hosts.

Fonts are self-hosted​

The UI typeface, Roboto, is part of each app's own build: every root layout of apps/web, apps/admin, apps/id and apps/stats loads it with next/font/local from the pinned npm package @fontsource-variable/roboto, and Next.js serves the WOFF2 files from /_next/static/media/ on the app's origin. font-src 'self' covers them; no app needs a font host in its policy for the UI typeface (see Dashboard UI Primitives).

The overlay root layout (Montserrat) and the extension font picker load fonts through the API font proxy: GET /v1/fonts/{family}/css returns @font-face rules whose src points at GET /v1/fonts/{family}/{weight}.woff2 on the API origin, and the API fetches each WOFF2 from Google once and serves it from its own storage. The dashboard font picker reaches the CSS through the same-origin /api/fonts/{family}/css route; the overlay links the API URL directly, because OBS browser sources carry no session cookie. The browser therefore never contacts fonts.googleapis.com or fonts.gstatic.com: the policy needs no Google host, and no viewer IP reaches Google from a stream overlay. If a family cannot be loaded, the picker shows its name unstyled; there is no Google fallback.

Accepted risk: style-src 'unsafe-inline'​

The enforcing policy keeps style-src 'unsafe-inline' (CIO decision, ZAF-995). React and Next.js emit inline style= attributes throughout the app, so style nonces are impractical. Style injection cannot execute code; its residual risk is CSS-based UI redress or attribute exfiltration, which the locked script-src, connect-src and img-src allowlists bound. script-src gets no such exception.

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.

Deployed-header smoke gate​

The connect-src fallbacks above are correct for local dev - and the unit tests assert them - so no unit test can notice when one of them reaches a real deploy. Staging once shipped connect-src 'self' http://localhost:3000 ws://localhost:3000 because its deploy env lacked LUMIO_PUBLIC_API_URL (ZAF-1311 / ZAF-1312). scripts/check-deployed-csp.mjs checks the header the deployed origin actually sends:

  • It fetches the page and reads Content-Security-Policy and Content-Security-Policy-Report-Only (whichever are present; neither is a failure).
  • Fails if any source-list directive carries a non-public origin: localhost, a loopback / private / link-local IP, a dot-less internal Docker host such as staging-api:3000, or a plaintext http: / ws: origin.
  • Fails if connect-src lacks the environment's public API origin over both https:// and wss:// (lumio.api.staging.zaflun.dev for staging, api.lumio.vision for production).
node scripts/check-deployed-csp.mjs --target staging # after a staging deploy
node scripts/check-deployed-csp.mjs --target prod
node scripts/check-deployed-csp.mjs --url <origin> --expect-api <https://api-origin>
node scripts/check-deployed-csp.mjs --self-test # synthetic leaks, no network

The Deployed CSP Check workflow runs it daily against staging and alerts the security Discord channel when it fails. You can also start it by hand (workflow_dispatch, target staging or prod) right after a deploy. On PRs that touch the checker it runs only the self-test, so a broken staging never blocks an unrelated PR. Production is checked on demand only for now, because the production web origin does not yet serve the app CSP. When apps/admin or apps/id get a CSP, add their origins as targets in the script.

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 is anonymised at 12 months before its rows are deleted at 24).

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 'unsafe-inline' with a per-request nonce plus 'strict-dynamic', emitted from proxy.ts (ZAF-996). style-src 'unsafe-inline' stays (accepted risk, above).
  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.