fix(audit-wave-11): CSP nonce middleware — drops 'unsafe-inline' in prod

build-auditor H1: prod `script-src` previously kept `'unsafe-inline'`
because dropping it requires a per-request nonce that Next's RSC
bootstrap + Server Actions can thread into their inline scripts.

Implement the nonce mechanism in `src/proxy.ts`:

1. Mint a base64-encoded UUID per request as the CSP nonce.
2. Set the nonce on the REQUEST headers via
   `content-security-policy` + `x-nonce` so Next.js's RSC layer reads
   the active CSP and stamps `nonce=<value>` onto every inline
   `<script>` it emits (Next's documented pattern).
3. Set the matching `Content-Security-Policy` on the RESPONSE so the
   browser actually enforces it.

Prod CSP becomes:
  `script-src 'self' 'nonce-<value>' 'strict-dynamic'`

`'strict-dynamic'` lets nonce-tagged scripts load further scripts they
trust, which is how Next chunks the rest of the bundle in. Inline
`<script>` without a nonce is now rejected by the browser — closes
the canonical XSS pathway.

Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next's HMR evaluates
code at runtime and the nonce machinery doesn't reach it.

`style-src` keeps `'unsafe-inline'` because Tailwind + Radix runtime
style injection has no nonce story yet. Revisit when Tailwind v5
ships a nonce-able API.

The static CSP in `next.config.ts` stays as a fallback for static
assets / API JSON paths that don't run through the proxy. Updated
the comment so future readers know the proxy CSP takes precedence
for HTML responses.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 13:04:30 +02:00
parent b4e502fedd
commit 19002f4c21
2 changed files with 83 additions and 9 deletions

View File

@@ -43,13 +43,15 @@ const withBundleAnalyzer = bundleAnalyzer({
const devScriptHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
const devConnectHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
// `'unsafe-inline'` on script-src is a known weakness flagged by the
// build-auditor (H1). Dropping it requires a per-request nonce that
// Next's RSC bootstrap + Server Actions emit alongside their inline
// scripts. Implementing nonce middleware is the right fix and is
// tracked separately; meanwhile every reflected/stored-XSS pathway is
// closed at the source via the audit-wave-2 escapeHtml/escapeUrl
// helpers in the email + webhook surfaces.
// Fallback CSP for paths the proxy doesn't run on (static assets,
// API JSON responses where script-src is moot). Production HTML
// responses get a stricter per-request nonce-based CSP set in
// `src/proxy.ts:applyCsp`; this header just provides a sane default
// so a misconfigured static-only route still has a CSP.
//
// Dev keeps 'unsafe-inline' + 'unsafe-eval' on script-src because
// Next's HMR runtime evaluates code dynamically and the nonce
// machinery doesn't reach it.
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,

View File

@@ -1,6 +1,49 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
/**
* Per-request CSP nonce — drops `'unsafe-inline'` from script-src in
* prod by giving every inline script a unique nonce that Next reads
* from the `content-security-policy` REQUEST header and threads through
* its RSC bootstrap + Server Actions. build-auditor H1.
*
* Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next HMR injects
* runtime-evaluated scripts the nonce mechanism doesn't reach.
* style-src stays at `'unsafe-inline'` because Tailwind/Radix runtime
* style injection has no nonce story yet (revisit when Tailwind v5
* ships a nonce-able API).
*/
function buildCspWithNonce(nonce: string, isProd: boolean): string {
const scriptSrc = isProd
? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`
: "script-src 'self' 'unsafe-inline' 'unsafe-eval' http://unpkg.com https://unpkg.com";
const connectSrc = isProd
? "connect-src 'self' ws: wss: https:"
: "connect-src 'self' ws: wss: https: http://unpkg.com https://unpkg.com";
return [
"default-src 'self'",
scriptSrc,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
connectSrc,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join('; ');
}
function generateNonce(): string {
// crypto.randomUUID is collision-free across requests; base64-encode
// to match the CSP spec shape (no `-`/`_`-only restrictions for
// CSP3 nonce values, but base64 stays safe across HTTP header
// serialization).
return Buffer.from(crypto.randomUUID()).toString('base64');
}
/**
* Paths that do not require an authenticated session.
* Checked with startsWith, so /auth/ covers /auth/callback etc.
@@ -74,9 +117,29 @@ function originAllowed(request: NextRequest): boolean {
return true;
}
/**
* Apply per-request CSP to a NextResponse. Skipped for API routes
* (they don't render HTML so script-src is irrelevant) and for
* already-built responses that have set their own CSP (e.g. redirects
* where the static next.config CSP applies).
*/
function applyCsp(response: NextResponse, nonce: string, pathname: string): NextResponse {
if (isApiRoute(pathname)) return response;
const isProd = process.env.NODE_ENV === 'production';
response.headers.set('Content-Security-Policy', buildCspWithNonce(nonce, isProd));
response.headers.set('x-nonce', nonce);
return response;
}
export function proxy(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
// Mint a per-request nonce up-front so HTML responses can carry it.
// Cheap (one UUID + base64) so we always do it; the apply step
// skips API routes that don't need it.
const nonce = generateNonce();
const isProd = process.env.NODE_ENV === 'production';
// CSRF defense-in-depth: state-changing requests to authed /api/v1
// endpoints must come from the app's own origin. Skipped in dev so
// LAN testing (e.g. real iPhone hitting the Mac via 192.168.x.x while
@@ -96,7 +159,13 @@ export function proxy(request: NextRequest): NextResponse {
// Always allow public paths through
if (isPublicPath(pathname)) {
return NextResponse.next();
// Forward the nonce in the REQUEST header so Next's RSC bootstrap
// can read it via `headers()` and stamp it onto every inline
// <script>. The browser-facing CSP is set on the response below.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
requestHeaders.set('x-nonce', nonce);
return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
}
const sessionToken = request.cookies.get('pn-crm.session_token');
@@ -113,7 +182,10 @@ export function proxy(request: NextRequest): NextResponse {
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
const requestHeaders = new Headers(request.headers);
requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
requestHeaders.set('x-nonce', nonce);
return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
}
export const config = {