diff --git a/next.config.ts b/next.config.ts index d6285b41..8fe29292 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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}`, diff --git a/src/proxy.ts b/src/proxy.ts index a76bba6a..c36fa951 100644 --- a/src/proxy.ts +++ b/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 + //