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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,14 @@
|
|||||||
import { buildSecurityHeaders } from "@repo/core-shared/security";
|
import { withSecurityHeaders } from "@repo/core-shared/security/next";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest, NextResponse } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export function middleware(_request: NextRequest): NextResponse {
|
// Payload's admin UI is served by this Next.js app and is always dynamically
|
||||||
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
|
// rendered, so the shared nonce-based middleware works here: it generates a
|
||||||
const secHeaders = buildSecurityHeaders({ mode });
|
// 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
|
||||||
const response = NextResponse.next();
|
// the admin's scripts. Without a nonce, the prod CSP's `strict-dynamic`
|
||||||
for (const [name, value] of Object.entries(secHeaders)) {
|
// script-src would block every Payload admin script.
|
||||||
response.headers.set(name, value);
|
export function middleware(request: NextRequest): NextResponse {
|
||||||
}
|
return withSecurityHeaders(request);
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -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());
|
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", () => {
|
it("CSP is permissive in development mode", () => {
|
||||||
@@ -70,12 +72,31 @@ describe("cms middleware", () => {
|
|||||||
expect(csp).toContain("'unsafe-inline'");
|
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");
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
|
||||||
middleware(makeRequest());
|
middleware(makeRequest());
|
||||||
|
|
||||||
const csp = mock._store.get("Content-Security-Policy");
|
const csp = mock._store.get("Content-Security-Policy");
|
||||||
|
const nonce = mock._store.get("x-nonce");
|
||||||
expect(csp).toContain("'strict-dynamic'");
|
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"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user