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/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 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 |
| Deployed-header smoke gate | scripts/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-src | Resolution chain (first match wins) |
|---|---|
| API HTTP | LUMIO_PUBLIC_API_URL → NEXT_PUBLIC_API_URL → LUMIO_API_URL → http://localhost:3000/v1 |
| API WebSocket | LUMIO_WS_URL (bare host) → derived from the API HTTP origin (http→ws, https→wss) |
| ID app | NEXT_PUBLIC_ID_URL → LUMIO_ID_URL |
| This app | NEXT_PUBLIC_APP_URL → LUMIO_APP_URL |
| Overlay | NEXT_PUBLIC_OVERLAY_URL → LUMIO_OVERLAY_URL |
| Extension supervisor | LUMIO_SUPERVISOR_URL → NEXT_PUBLIC_SUPERVISOR_URL (getConfiguredSupervisorUrl(), the same chain the layouts hand to the framing components; unset adds no source) |
| Sentry | LUMIO_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_DSNhas an inert browser SDK (no ingest requests) and no Sentry origin inconnect-src; a container with it gets both. The server-onlySENTRY_DSNis used by server/edge Sentry and never needs a browser allowance. The webapp mounts no analytics script, soconnect-srcandscript-srccarry 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_URLsits last in the HTTP chain on purpose: in local dev the API is configured through it alone (the documented.env.examplesets noNEXT_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 orconnect-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
/v1prefix lives in the env, not in code.NEXT_PUBLIC_API_URLcarries the/v1API-version segment (in.env.example, in the deployed per-environment images, and in the code fallback). WebSocket URLs are built bywsUrlFromApiUrl()inapps/web/src/lib/config.ts, which swaps the scheme and appends only the WS-specific/wssuffix - so a base already carrying/v1becomes…/v1/wsand is never doubled to/v1/v1/ws. Every WS hook shares that one helper. The lone exception is the bareLUMIO_WS_URLhost (no path), wheregetWsUrl()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.comfor YouTube avatars and member badges,yt4.ggpht.comfor YouTube chat author photos), Spotify album art (i.scdn.co), the test-event avatars and album art (api.dicebear.com, fromapps/web/src/lib/test-events.ts), plusdata:/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 ownframe-srcgoverns 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 throughpostMessageto 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 fromproxy.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-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.
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-PolicyandContent-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 asstaging-api:3000, or a plaintexthttp:/ws:origin. - Fails if
connect-srclacks the environment's public API origin over bothhttps://andwss://(lumio.api.staging.zaflun.devfor staging,api.lumio.visionfor 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_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 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)
- 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'unsafe-inline'with a per-request nonce plus'strict-dynamic', emitted fromproxy.ts(ZAF-996).style-src 'unsafe-inline'stays (accepted risk, above). - 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.