security(csp): migrate script-src from 'unsafe-inline' to per-request nonce#401
Conversation
… nonce
Production `script-src` is now `'self' 'nonce-<per-request>' 'strict-dynamic'`
with no `'unsafe-inline'`. The nonce is generated per request in the Next 16
proxy (`src/proxy.ts`), threaded into SSR via the `x-nonce` and CSP request
headers so Next stamps its own bootstrap/bundle/flight scripts automatically,
and applied explicitly to the one hand-authored inline script (the theme-flash
guard in `src/app/layout.tsx`). Reading the nonce opts the app into dynamic
rendering, which is inherent to per-request nonces.
CSP now lives in the proxy (a nonce can't be a build-time constant); all other
CSP directives and every other security header are unchanged and still emitted
statically from `next.config.ts` via `buildSecurityHeaders` (CSP removed from
that set so there is exactly one CSP header per response).
Development keeps the pre-migration `'self' 'unsafe-inline' 'unsafe-eval'` with
no `'strict-dynamic'`: the Turbopack dev server injects non-nonced HMR and
route-chunk `<script>` tags that `'strict-dynamic'` (which disables the `'self'`
allow-list) would block. Dev is not the shipped security boundary.
Silence Zod 4's JIT probe on the client (`src/instrumentation-client.ts`,
`jitless: true`): under the strict prod CSP it fires a swallowed
`new Function("")` that the browser still reports as a securitypolicyviolation.
The server keeps JIT (no CSP there).
Verified: `npm run build`, full-Chromium `npm run verify:ui` (120 passed), and a
scripted `next start` console sweep of answer / search / document-viewer / auth
flows with zero CSP violations under the strict prod policy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
CI's verify:ui only exercises the development CSP path (Turbopack keeps 'unsafe-inline'), so the strict shipped policy had no automated coverage. These proxy unit tests run under NODE_ENV=test, which takes the production branch of buildContentSecurityPolicy, and assert the response CSP is 'self' 'nonce-<per-request>' 'strict-dynamic' with no 'unsafe-inline'/'unsafe-eval', that every other directive is unchanged, that the nonce is fresh per request, and that the same nonce is threaded into the SSR request headers (x-nonce). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Follow-up: added |
next.config.ts (static headers) and src/proxy.ts (per-request nonce CSP) each hand-computed isDevelopment / isLocalHttpRuntime. Extract them into resolveRuntimeFlags() in security-headers.ts so the HTTPS-only hardening gate (HSTS, upgrade-insecure-requests) and the script-src gate can't drift apart. Behaviour is identical; adds focused tests for the flag derivation, including the security-relevant PLAYWRIGHT_BASE_URL local-http case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
What
Migrates the production Content-Security-Policy
script-srcfrom'unsafe-inline'to a per-request nonce +'strict-dynamic'policy. Every other CSP directive and every other security header is preserved exactly.Closes the L19 deferral in
docs/process-hardening.md("CSPscript-src 'unsafe-inline'deferred; nonce migration needs dedicated UI verification").How it works (Next 16 App Router)
src/proxy.tsgenerates a fresh base64 nonce per request, threads it into the SSR request via thex-nonceheader and the CSP request header (Next parses the latter to stamp its own scripts), and sets the enforcedContent-Security-Policyon every response. CSP moved here because a nonce cannot be a build-time constant.next.config.ts/buildSecurityHeadersstill emits all the other security headers statically for every route (including static assets);Content-Security-Policywas removed from that set so there is exactly one CSP header per response (two would be enforced as their intersection and break the app).src/app/layout.tsxreadsx-nonceand stamps the one hand-authored inline script — the theme-flash guard — which Next does not auto-nonce. Readingheaders()opts the app into dynamic rendering (inherent to per-request nonces;generateStaticParamspages are nowƒdynamic, build stays green).suppressHydrationWarningis added because Next strips the nonce from the client payload.Which inline scripts were nonce-tagged
<script>insrc/app/layout.tsx— verified in prod HTML that itsnonceattribute equals the CSP header nonce.useReportWebVitalsinline scripts are nonced automatically by Next once the nonce is present in the request CSP header. Confirmed loading cleanly under'strict-dynamic'in a production server.next/scriptcomponents, or JSON-LD blocks in the app (grep-verified).Development exception
Dev keeps the pre-migration
script-src 'self' 'unsafe-inline' 'unsafe-eval'(no'strict-dynamic'). The Turbopack dev server injects non-nonced HMR/route-chunk<script src>tags, and'strict-dynamic'disables the'self'allow-list, so it would blank the dev app. Dev is not the shipped security boundary (it already relaxes with'unsafe-eval').Zod JIT eval probe
The strict prod CSP surfaced a pre-existing, benign violation (already blocked under the old prod policy, which also lacked
'unsafe-eval'): Zod 4's JIT compiler probes for eval withnew Function("")inside a swallowedtry/catch, which the browser still reports as asecuritypolicyviolation. Silenced by setting Zodjitless: trueon the client insrc/instrumentation-client.ts(runs before hydration; server keeps fast JIT — no CSP there). This is Zod's documented remedy for strict CSPs.Verification (clean console across smoke flows)
npm run build— green; nonce forces dynamic rendering, no build errors.npm run verify:ui(full Chromium) — 120 passed, 0 failed.next start, live env) console sweep of answer render (/,/?mode=answer), search (/documents/search), document viewer (/documents/source), medications/differentials/favourites, and an auth interaction → zerosecuritypolicyviolation; confirmed servedscript-srcis'self' 'nonce-…' 'strict-dynamic'with no'unsafe-inline'/'unsafe-eval'and the theme-script nonce matches the header.tests/security-headers.test.tsupdated for the dev/prod split (17 passed).🤖 Generated with Claude Code