From cd61b31e652c13ba3c788e3627044b0a0310722a Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 10 Jul 2026 17:25:04 +0200 Subject: [PATCH] fix(cms): thread a per-request nonce through the admin CSP The prod CSP emitted script-src 'strict-dynamic' with no nonce seed, blocking every Payload admin script (A8). Reuse the shared nonce-based withSecurityHeaders: Payload admin pages are always dynamically rendered, so Next propagates the nonce read from the forwarded request's CSP header onto the admin's scripts. Co-Authored-By: Claude Fable 5 --- apps/cms/middleware.ts | 22 ++++++++++------------ apps/cms/src/middleware.test.ts | 27 ++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/cms/middleware.ts b/apps/cms/middleware.ts index 72db1ee..2006ec4 100644 --- a/apps/cms/middleware.ts +++ b/apps/cms/middleware.ts @@ -1,16 +1,14 @@ -import { buildSecurityHeaders } from "@repo/core-shared/security"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; +import { withSecurityHeaders } from "@repo/core-shared/security/next"; +import type { NextRequest, NextResponse } from "next/server"; -export function middleware(_request: NextRequest): NextResponse { - const mode = process.env.NODE_ENV === "production" ? "prod" : "dev"; - const secHeaders = buildSecurityHeaders({ mode }); - - const response = NextResponse.next(); - for (const [name, value] of Object.entries(secHeaders)) { - response.headers.set(name, value); - } - return response; +// Payload's admin UI is served by this Next.js app and is always dynamically +// rendered, so the shared nonce-based middleware works here: it generates a +// per-request nonce, threads it into the CSP, and sets the CSP on the +// forwarded request headers — which is how Next propagates the nonce onto +// the admin's scripts. Without a nonce, the prod CSP's `strict-dynamic` +// script-src would block every Payload admin script. +export function middleware(request: NextRequest): NextResponse { + return withSecurityHeaders(request); } export const config = { diff --git a/apps/cms/src/middleware.test.ts b/apps/cms/src/middleware.test.ts index 0ba9468..b7151bf 100644 --- a/apps/cms/src/middleware.test.ts +++ b/apps/cms/src/middleware.test.ts @@ -55,10 +55,12 @@ describe("cms middleware", () => { } }); - it("does not set a nonce header", () => { + it("sets a per-request nonce header on the response", () => { middleware(makeRequest()); - expect(mock._store.has("x-nonce")).toBe(false); + const nonce = mock._store.get("x-nonce"); + expect(nonce).toBeDefined(); + expect((nonce as string).length).toBeGreaterThan(0); }); it("CSP is permissive in development mode", () => { @@ -70,12 +72,31 @@ describe("cms middleware", () => { expect(csp).toContain("'unsafe-inline'"); }); - it("CSP uses strict-dynamic in production mode", () => { + it("production CSP uses strict-dynamic seeded with the nonce", () => { vi.stubEnv("NODE_ENV", "production"); middleware(makeRequest()); const csp = mock._store.get("Content-Security-Policy"); + const nonce = mock._store.get("x-nonce"); expect(csp).toContain("'strict-dynamic'"); + expect(csp).toContain(`'nonce-${nonce}'`); + }); + + it("forwards the CSP + nonce on the request headers so Next can propagate it to Payload's scripts", () => { + vi.stubEnv("NODE_ENV", "production"); + + middleware(makeRequest()); + + const call = vi.mocked(NextResponse.next).mock.calls[0] as [ + { request?: { headers?: Headers } } | undefined, + ]; + const requestHeaders = call[0]?.request?.headers; + const requestCsp = requestHeaders?.get("Content-Security-Policy"); + const nonce = requestHeaders?.get("x-nonce"); + expect(requestCsp).toBeTruthy(); + expect(nonce).toBeTruthy(); + expect(requestCsp).toContain(`'nonce-${nonce}'`); + expect(requestCsp).toBe(mock._store.get("Content-Security-Policy")); }); });