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. 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-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 / runtime | NEXT_PUBLIC_SUPERVISOR_URL / NEXT_PUBLIC_EXTENSION_RUNTIME_URL (or LUMIO_* twins) |
| Sentry | NEXT_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_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), 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.