diff --git a/frontend/src/__tests__/csp/cspNoncePlumbing.test.ts b/frontend/src/__tests__/csp/cspNoncePlumbing.test.ts index 5a79bb6..942cbf2 100644 --- a/frontend/src/__tests__/csp/cspNoncePlumbing.test.ts +++ b/frontend/src/__tests__/csp/cspNoncePlumbing.test.ts @@ -2,8 +2,8 @@ * Phase 5F-A: CSP nonce plumbing tests. * * Validates: - * 1. Nonce appears in document CSP header - * 2. Nonce differs across repeated requests + * 1. Document CSP remains hydration-safe for the Next.js runtime + * 2. CSP is deterministic across repeated requests * 3. next.config.ts no longer owns a static CSP header * 4. Middleware does not break API/static routes (matcher exclusion) * 5. Google Fonts domains are preserved in CSP @@ -41,58 +41,46 @@ function matcherExcludes(path: string): boolean { } // --------------------------------------------------------------------------- -// 1. Nonce appears in document CSP header +// 1. Document CSP remains hydration-safe // --------------------------------------------------------------------------- -describe('nonce in CSP header', () => { - it('CSP header contains a nonce- token in script-src', () => { +describe('hydration-safe CSP header', () => { + it('CSP header does not put nonce tokens in script-src', () => { const csp = getCsp(); - expect(csp).toMatch(/'nonce-[A-Za-z0-9+/=]+'/) ; + expect(csp).not.toMatch(/'nonce-[A-Za-z0-9+/=]+'/); }); - it('nonce value is a base64-encoded UUID', () => { + it('script-src keeps the inline compatibility fallback required by Next hydration', () => { const csp = getCsp(); - const match = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/); - expect(match).not.toBeNull(); - const decoded = Buffer.from(match![1], 'base64').toString(); - // crypto.randomUUID() produces 8-4-4-4-12 hex with dashes - expect(decoded).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/); + expect(csp).toMatch(/script-src [^;]*'unsafe-inline'/); }); - it('x-nonce request header is set on the response', () => { - const res = callMiddleware(); - // NextResponse.next({ request: { headers } }) merges into request headers. - // The CSP nonce in the header must match the one forwarded to server components. - const csp = res.headers.get('Content-Security-Policy') ?? ''; - const nonceInCsp = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1]; - expect(nonceInCsp).toBeTruthy(); + it('middleware still returns a CSP header for document requests', () => { + const csp = getCsp(); + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("script-src 'self'"); }); }); // --------------------------------------------------------------------------- -// 2. Nonce differs across repeated requests +// 2. CSP is deterministic across repeated requests // --------------------------------------------------------------------------- -describe('nonce uniqueness', () => { - it('two sequential requests produce different nonces', () => { +describe('CSP stability', () => { + it('two sequential requests produce the same document CSP', () => { const csp1 = getCsp(); const csp2 = getCsp(); - const nonce1 = csp1.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1]; - const nonce2 = csp2.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1]; - expect(nonce1).toBeTruthy(); - expect(nonce2).toBeTruthy(); - expect(nonce1).not.toBe(nonce2); + expect(csp1).toBe(csp2); }); - it('ten requests produce ten distinct nonces', () => { - const nonces = new Set(); + it('ten requests do not introduce nonce-bearing CSP variants', () => { + const csps = new Set(); for (let i = 0; i < 10; i++) { const csp = getCsp(); - const nonce = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1]; - expect(nonce).toBeTruthy(); - nonces.add(nonce!); + expect(csp).not.toMatch(/'nonce-[A-Za-z0-9+/=]+'/); + csps.add(csp); } - expect(nonces.size).toBe(10); + expect(csps.size).toBe(1); }); }); @@ -185,8 +173,9 @@ describe('production CSP directive completeness', () => { expect(csp).toContain("default-src 'self'"); }); - it('has script-src with nonce', () => { - expect(csp).toMatch(/script-src [^;]*'nonce-/); + it('has script-src with hydration compatibility fallback', () => { + expect(csp).toMatch(/script-src [^;]*'unsafe-inline'/); + expect(csp).not.toMatch(/script-src [^;]*'nonce-/); }); it('has style-src with unsafe-inline and fonts.googleapis.com', () => { diff --git a/frontend/src/__tests__/csp/cspProductionHardening.test.ts b/frontend/src/__tests__/csp/cspProductionHardening.test.ts index 3f46505..0fa2d9b 100644 --- a/frontend/src/__tests__/csp/cspProductionHardening.test.ts +++ b/frontend/src/__tests__/csp/cspProductionHardening.test.ts @@ -2,7 +2,7 @@ * Phase 5F-B: Production script-src nonce hardening tests. * * Validates: - * 1. Production CSP preserves nonce-based script execution with a compatibility + * 1. Production CSP preserves hydration-safe script execution with a compatibility * inline fallback required by the Next.js production runtime * 2. Dev CSP retains 'unsafe-inline' and 'unsafe-eval' * 3. Unchanged directives (style-src, font-src, worker-src, etc.) intact @@ -42,7 +42,7 @@ function matcherExcludes(path: string): boolean { } // --------------------------------------------------------------------------- -// 1. Production CSP keeps nonce hardening without blocking Next hydration +// 1. Production CSP stays hardened without blocking Next hydration // --------------------------------------------------------------------------- describe('production script-src hardening', () => { @@ -63,9 +63,9 @@ describe('production script-src hardening', () => { expect(scriptSrc).not.toContain("'unsafe-eval'"); }); - it('production script-src contains nonce', () => { + it('production script-src does not contain nonce until all Next inline scripts are wired', () => { const scriptSrc = getDirective('script-src'); - expect(scriptSrc).toMatch(/'nonce-[A-Za-z0-9+/=]+'/); + expect(scriptSrc).not.toMatch(/'nonce-[A-Za-z0-9+/=]+'/); }); it('production script-src contains self and blob:', () => { @@ -106,9 +106,9 @@ describe('dev script-src allowances', () => { expect(scriptSrc).toContain("'unsafe-eval'"); }); - it('dev script-src still contains nonce', () => { + it('dev script-src also omits nonce to match production hydration behavior', () => { const scriptSrc = getDirective('script-src'); - expect(scriptSrc).toMatch(/'nonce-[A-Za-z0-9+/=]+'/); + expect(scriptSrc).not.toMatch(/'nonce-[A-Za-z0-9+/=]+'/); }); it('dev connect-src includes localhost backends', () => { diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index d10fa5c..68d2202 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -8,7 +8,7 @@ import { NextRequest, NextResponse } from 'next/server'; -function buildCsp(nonce: string): string { +function buildCsp(_nonce: string): string { const isDev = process.env.NODE_ENV !== 'production'; const directives = [ "default-src 'self'",