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:
76
src/proxy.ts
76
src/proxy.ts
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user