Skip to content

security(csp): migrate script-src from 'unsafe-inline' to per-request nonce#401

Merged
BigSimmo merged 3 commits into
mainfrom
claude/csp-nonce-migration
Jul 8, 2026
Merged

security(csp): migrate script-src from 'unsafe-inline' to per-request nonce#401
BigSimmo merged 3 commits into
mainfrom
claude/csp-nonce-migration

Conversation

@BigSimmo

@BigSimmo BigSimmo commented Jul 8, 2026

Copy link
Copy Markdown
Owner

What

Migrates the production Content-Security-Policy script-src from '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 ("CSP script-src 'unsafe-inline' deferred; nonce migration needs dedicated UI verification").

How it works (Next 16 App Router)

  • src/proxy.ts generates a fresh base64 nonce per request, threads it into the SSR request via the x-nonce header and the CSP request header (Next parses the latter to stamp its own scripts), and sets the enforced Content-Security-Policy on every response. CSP moved here because a nonce cannot be a build-time constant.
  • next.config.ts / buildSecurityHeaders still emits all the other security headers statically for every route (including static assets); Content-Security-Policy was 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.tsx reads x-nonce and stamps the one hand-authored inline script — the theme-flash guard — which Next does not auto-nonce. Reading headers() opts the app into dynamic rendering (inherent to per-request nonces; generateStaticParams pages are now ƒ dynamic, build stays green). suppressHydrationWarning is added because Next strips the nonce from the client payload.

Which inline scripts were nonce-tagged

  • Hand-authored: the theme-flash-prevention <script> in src/app/layout.tsx — verified in prod HTML that its nonce attribute equals the CSP header nonce.
  • Framework (auto): Next's bootstrap, per-page bundle, RSC flight-data, and useReportWebVitals inline 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.
  • There are no other hand-authored inline scripts, next/script components, 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 with new Function("") inside a swallowed try/catch, which the browser still reports as a securitypolicyviolation. Silenced by setting Zod jitless: true on the client in src/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.
  • Scripted production server (next start, live env) console sweep of answer render (/, /?mode=answer), search (/documents/search), document viewer (/documents/source), medications/differentials/favourites, and an auth interactionzero securitypolicyviolation; confirmed served script-src is 'self' 'nonce-…' 'strict-dynamic' with no 'unsafe-inline'/'unsafe-eval' and the theme-script nonce matches the header.
  • Unit: tests/security-headers.test.ts updated for the dev/prod split (17 passed).

🤖 Generated with Claude Code

… 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>
@supabase

supabase Bot commented Jul 8, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project sjrfecxgysukkwxsowpy because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

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>
@BigSimmo

BigSimmo commented Jul 8, 2026

Copy link
Copy Markdown
Owner Author

Follow-up: added tests/proxy.test.ts to close the CI coverage gap. verify:ui only exercises the dev CSP path (Turbopack keeps 'unsafe-inline'), so the strict shipped policy previously had no automated coverage. The 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' (no 'unsafe-inline'/'unsafe-eval'), every other directive is unchanged, the nonce is fresh per request, and the same nonce is threaded into the SSR x-nonce request header. Now runs in npm run test / verify:cheap.

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>
@BigSimmo
BigSimmo marked this pull request as ready for review July 8, 2026 13:47
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@BigSimmo
BigSimmo merged commit 1bcb71c into main Jul 8, 2026
8 checks passed
@BigSimmo
BigSimmo deleted the claude/csp-nonce-migration branch July 8, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant