fix(core-shared): set CSP on forwarded request headers for nonce

Next.js only injects the nonce into its own scripts when it can read it
from the request's Content-Security-Policy header. Setting the CSP only
on the response left hydration scripts un-nonced in production (A9).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:24:01 +02:00
parent dcbf782e21
commit 00fcc9d9a1
2 changed files with 35 additions and 0 deletions

View File

@@ -90,6 +90,34 @@ describe("withSecurityHeaders", () => {
expect(call[0]?.request?.headers?.get("x-nonce")).toBeTruthy(); expect(call[0]?.request?.headers?.get("x-nonce")).toBeTruthy();
}); });
it("sets the CSP on the forwarded request headers (Next reads the nonce from there)", () => {
withSecurityHeaders(makeRequest());
const call = vi.mocked(NextResponse.next).mock.calls[0] as [
{ request?: { headers?: Headers } } | undefined,
];
const requestCsp = call[0]?.request?.headers?.get(
"Content-Security-Policy",
);
expect(requestCsp).toBeTruthy();
expect(requestCsp).toBe(mock._store.get("Content-Security-Policy"));
});
it("request-header CSP carries the nonce in production mode", () => {
vi.stubEnv("NODE_ENV", "production");
withSecurityHeaders(makeRequest());
const call = vi.mocked(NextResponse.next).mock.calls[0] as [
{ request?: { headers?: Headers } } | undefined,
];
const requestCsp = call[0]?.request?.headers?.get(
"Content-Security-Policy",
);
const nonce = call[0]?.request?.headers?.get("x-nonce");
expect(requestCsp).toContain(`'nonce-${nonce}'`);
});
it("returns the NextResponse from NextResponse.next", () => { it("returns the NextResponse from NextResponse.next", () => {
const result = withSecurityHeaders(makeRequest()); const result = withSecurityHeaders(makeRequest());

View File

@@ -7,9 +7,16 @@ export function withSecurityHeaders(request: NextRequest): NextResponse {
const nonce = generateNonce(); const nonce = generateNonce();
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev"; const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
const secHeaders = buildSecurityHeaders({ mode, nonce }); const secHeaders = buildSecurityHeaders({ mode, nonce });
const csp = secHeaders["Content-Security-Policy"];
const requestHeaders = new Headers(request.headers); const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce); requestHeaders.set("x-nonce", nonce);
// Next.js only propagates a nonce to its own <script> tags when it can
// read it from the *request's* Content-Security-Policy header, so the CSP
// must be set on the forwarded request headers — not just the response.
if (csp) {
requestHeaders.set("Content-Security-Policy", csp);
}
const response = NextResponse.next({ const response = NextResponse.next({
request: { headers: requestHeaders }, request: { headers: requestHeaders },