fix(web): replace the CSP unsafe-inline script policy with a per-request nonce (#936) - #1050
Conversation
…est nonce (#936) Production shipped `script-src 'self' 'unsafe-inline'`, and the file's own comment conceded the consequence: with the JWT in localStorage and inline scripts allowed, any future HTML-injection bug was full session theft. connect-src/img-src closed fetch/XHR/img exfiltration, but nothing could close top-level navigation (`window.location = attacker + token`) once the injected script was already executing. A nonce fixes the cause rather than the channels: an injected inline script has no nonce, so it never runs. - src/proxy.ts mints a nonce per request (crypto.getRandomValues on the Edge runtime) and sets the CSP on BOTH the request headers — which is how Next.js knows to stamp the nonce onto its own bootstrap scripts — and the response. - buildScriptSrc() emits `'self' 'nonce-<n>' 'strict-dynamic'` with no 'unsafe-inline'. unsafe-eval stays dev-only. style-src deliberately keeps 'unsafe-inline': Tailwind/React set style attributes directly, and a stolen style is not a stolen session. - securityHeaders() no longer carries a CSP. Two CSP headers on one response are intersected by the browser, so a static nonce-less policy alongside the proxy's would have blocked every script. - The root layout sets `export const dynamic = 'force-dynamic'`. Next.js can only stamp a per-request nonce onto a document it renders at request time. Measured before adding it: on the statically prerendered /login, 0 of 21 scripts carried the nonce — a blank page — while the dynamic /sessions/[id] had 22 of 22. The build succeeds either way, so this coupling has a test. Verified against a real `next start`: distinct nonce per request, and /login, /tasks and /settings each serve every script and preload with the nonce. 15 CSP tests (the AC's CI check asserts no 'unsafe-inline' in the production script-src); full web-ui suite 1083 passed.
GLM Precision Review — CSP Nonce (#936)Bug-hunting review in progress. Posting findings as inline comments + a summary comment when done.
|
Two P2 findings from codex review, both real — I had not run `npm run lint`,
only jest and the build:
- src/proxy.ts used require(), which @typescript-eslint/no-require-imports
forbids and `npm run lint --max-warnings 0` treats as blocking. Switched to an
ESM import, with a new security-headers.d.ts so the CommonJS module (kept
CommonJS because next.config.js requires it and must stay dependency-free)
can be imported with types.
- securityHeaders() no longer reads env after the CSP moved out, so the unused
parameter tripped no-unused-vars. Dropped it.
Running the full gate then caught a third instance of the same rule in my own
test file (require('fs')/require('path')); converted to ESM imports.
`npm run lint` exit 0, 15 CSP tests pass, build clean, and the served CSP still
carries a per-request nonce with no 'unsafe-inline' (22 nonced elements on
/login).
Third-party review —
|
|
Claude finished @frankbria's task in 4m 8s —— View job Claude Precision Review — CSP Nonce (#936)Bug-hunting review complete. Scope: logic errors, security, races, contract breaks. (Style/coverage are out of scope — a separate general review covers those.)
✅ No defects found. What I verified (and one thing that looks like a bug but isn't)
Notes (not defects)
No inline comments posted. No files modified, no commits pushed (review-only). |

Closes #936.
The problem, in the file's own words
Production shipped
script-src 'self' 'unsafe-inline', andsecurity-headers.jsconceded what that meant:Closing exfiltration channels one at a time is a losing game once arbitrary script is already running. A nonce fixes the cause: an injected inline script has no nonce, so it never executes.
What changed
src/proxy.tsmints a nonce per request (crypto.getRandomValues, Edge-runtime safe) and sets the CSP on both the request headers — which is how Next.js knows to stamp the nonce onto its own bootstrap scripts — and the response headers, which is what the browser enforces.buildScriptSrc()emits'self' 'nonce-<n>' 'strict-dynamic', with no'unsafe-inline'.unsafe-evalremains dev-only.securityHeaders()no longer carries a CSP. Two CSP headers on one response are intersected by the browser, so leaving a static nonce-less policy alongside the proxy's would have blocked every script.layout.tsxsetsexport const dynamic = 'force-dynamic'— see below.style-srcdeliberately keeps'unsafe-inline': Tailwind and React set style attributes directly, and a stolen style is not a stolen session. The threat here is script execution. A test pins that decision so changing it has to be deliberate.The part the build does not catch
Next.js can only stamp a per-request nonce onto a document it renders at request time. A statically prerendered page keeps whatever was baked in at build time — nothing.
Measured against a real
next start, before addingforce-dynamic:/login/sessions/[id]The build succeeded in both cases. Shipping the proxy without
force-dynamicwould have produced a white screen on 14 of 17 routes with a green CI.After
force-dynamic, every route isƒand every script and preload is nonced:(Nonced exceeds the
<scriptcount because Next stamps preload<link>tags too.)Distinct nonce per request, confirmed on two consecutive requests:
Cost of
force-dynamicThese pages are
'use client'shells — the static prerender was an empty loading skeleton, and the data is fetched client-side either way. JS/CSS chunks are still served statically from/_next/static, which the proxy matcher skips. So the cost is rendering an empty shell per document request, not per-request data work.If that trade is unwanted, the AC's other option (moving the token to an httpOnly cookie) removes the need for the nonce entirely — but it is a much larger change: login response, axios interceptor, SSE stream tickets, WebSocket auth, the CLI API client, and CSRF protection.
Acceptance criteria
'unsafe-inline'inscript-src(per-request nonce middleware)'unsafe-inline'inscript-src—security-headers.test.js, which thefrontend-testsjob runsTests
15 CSP tests. Two guard the coupling that the build cannot: the root layout must keep
force-dynamic, andproxy.tsmust set the CSP on both header sets. One pre-existing test asserted the static header list contained a CSP; it is updated with a comment explaining why the CSP moved.Full web-ui suite: 1083 passed across 96 suites.
npm run buildclean.Known limitations
style-src 'unsafe-inline'stays, as above.force-dynamicis applied at the root layout, so it covers every route. A per-route opt-in would preserve static generation for any page that genuinely does not need the nonce — but every page in this app serves the authenticated shell, so there is nothing to exempt today.next/scriptor any inline<script>a future component might add; those would neednonce={await headers().get('x-nonce')}. The proxy already exposes it asx-noncefor that purpose.