Merge pull request #532 from Molecule-AI/fix/issue-450-csp-nonce

fix(canvas): nonce-based CSP replaces unsafe-inline/unsafe-eval in production
This commit is contained in:
Hongming Wang 2026-04-16 14:15:12 -07:00 committed by GitHub
commit 5caefc5909
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 203 additions and 1 deletions

View File

@ -0,0 +1,126 @@
/**
* Tests for the CSP nonce logic in canvas/src/middleware.ts
*
* Security issue #450: CSP used 'unsafe-inline' + 'unsafe-eval' globally,
* defeating the XSS protection the header is supposed to provide.
*
* Fix: nonce-based script-src in production; permissive only in dev.
*/
import { describe, it, expect } from "vitest";
import { buildCsp } from "../middleware";
const TEST_NONCE = "dGVzdC1ub25jZQ=="; // base64("test-nonce")
// ---------------------------------------------------------------------------
// Production CSP — the security-critical path
// ---------------------------------------------------------------------------
describe("buildCsp — production", () => {
const csp = buildCsp(TEST_NONCE, false);
function scriptSrc(): string {
return csp.match(/script-src[^;]*/)?.[0] ?? "";
}
it("does NOT contain 'unsafe-inline' in script-src (issue #450 fix)", () => {
expect(scriptSrc()).not.toContain("'unsafe-inline'");
});
it("does NOT contain 'unsafe-eval' in script-src (issue #450 fix)", () => {
expect(scriptSrc()).not.toContain("'unsafe-eval'");
});
it("embeds the nonce in script-src", () => {
expect(scriptSrc()).toContain(`'nonce-${TEST_NONCE}'`);
});
it("includes 'strict-dynamic' so Next.js chunks load without allow-listing every URL", () => {
expect(scriptSrc()).toContain("'strict-dynamic'");
});
it("locks object-src to 'none' (no plugins)", () => {
expect(csp).toContain("object-src 'none'");
});
it("locks base-uri to 'self' (prevents base-tag injection)", () => {
expect(csp).toContain("base-uri 'self'");
});
it("locks frame-ancestors to 'none' (prevents clickjacking)", () => {
expect(csp).toContain("frame-ancestors 'none'");
});
it("includes upgrade-insecure-requests", () => {
expect(csp).toContain("upgrade-insecure-requests");
});
it("allows wss: in connect-src (WebSocket to platform)", () => {
const connectSrc = csp.match(/connect-src[^;]*/)?.[0] ?? "";
expect(connectSrc).toContain("wss:");
});
it("does NOT include bare ws: in connect-src (prod uses wss only)", () => {
const connectSrc = csp.match(/connect-src[^;]*/)?.[0] ?? "";
// ws: (without 's') is insecure — should not be in production policy
// Note: "wss:" contains the substring "ws" so we check for word "ws:"
const parts = connectSrc.split(/\s+/);
expect(parts).not.toContain("ws:");
});
it("allows blob: in worker-src (React Flow / canvas workers)", () => {
const workerSrc = csp.match(/worker-src[^;]*/)?.[0] ?? "";
expect(workerSrc).toContain("blob:");
});
it("different nonces produce different CSPs", () => {
const csp2 = buildCsp("ZGlmZmVyZW50", false);
expect(csp).not.toBe(csp2);
});
});
// ---------------------------------------------------------------------------
// Development CSP — HMR / fast-refresh compatibility
// ---------------------------------------------------------------------------
describe("buildCsp — development", () => {
const csp = buildCsp(TEST_NONCE, true);
function scriptSrc(): string {
return csp.match(/script-src[^;]*/)?.[0] ?? "";
}
it("retains 'unsafe-inline' so Next.js HMR injects without errors", () => {
expect(scriptSrc()).toContain("'unsafe-inline'");
});
it("retains 'unsafe-eval' so fast-refresh / webpack eval() works", () => {
expect(scriptSrc()).toContain("'unsafe-eval'");
});
it("allows ws: in connect-src (HMR WebSocket uses plain ws://)", () => {
const connectSrc = csp.match(/connect-src[^;]*/)?.[0] ?? "";
expect(connectSrc).toContain("ws:");
});
});
// ---------------------------------------------------------------------------
// CSP format invariants (both modes)
// ---------------------------------------------------------------------------
describe("buildCsp — format invariants", () => {
for (const [label, csp] of [
["production", buildCsp(TEST_NONCE, false)],
["development", buildCsp(TEST_NONCE, true)],
] as const) {
it(`[${label}] ends with a semicolon`, () => {
expect(csp.trimEnd()).toMatch(/;$/);
});
it(`[${label}] contains default-src 'self'`, () => {
expect(csp).toContain("default-src 'self'");
});
it(`[${label}] allows blob: and data: for img-src (canvas avatars / thumbnails)`, () => {
const imgSrc = csp.match(/img-src[^;]*/)?.[0] ?? "";
expect(imgSrc).toContain("blob:");
expect(imgSrc).toContain("data:");
});
}
});

View File

@ -1,13 +1,89 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* Build a Content-Security-Policy header value.
*
* Production strict, nonce-based policy:
* script-src uses 'nonce-{nonce}' + 'strict-dynamic': eliminates both
* 'unsafe-inline' and 'unsafe-eval' (the two directives flagged in #450).
* 'strict-dynamic' propagates trust to dynamically-loaded Next.js chunks
* without needing to enumerate every chunk URL.
* style-src retains 'unsafe-inline': React Flow positions nodes via
* element-level style="" attributes which cannot be nonce'd; CSS injection
* is significantly lower risk than script injection and is acceptable here.
* object-src / base-uri / frame-ancestors locked to 'none'/'self'.
* upgrade-insecure-requests forces HTTPS on mixed-content.
*
* Development permissive policy:
* Next.js HMR and fast-refresh rely on eval() and inline scripts; a strict
* nonce policy breaks the dev server. In dev we preserve 'unsafe-inline' and
* 'unsafe-eval' so the developer experience is unchanged.
*
* Exported for unit testing.
*/
export function buildCsp(nonce: string, isDev: boolean): string {
if (isDev) {
return [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data:",
"font-src 'self'",
"connect-src 'self' ws: wss:",
"worker-src 'self' blob:",
].join("; ") + ";";
}
return [
"default-src 'self'",
// Nonce-based: no unsafe-inline, no unsafe-eval.
// 'strict-dynamic' propagates trust from nonce'd bootstrap script to
// all dynamically-imported Next.js chunks.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
// unsafe-inline kept for inline style="" attributes used by React Flow.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data:",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"connect-src 'self' wss:",
"worker-src 'self' blob:",
"upgrade-insecure-requests",
].join("; ") + ";";
}
export function middleware(request: NextRequest) {
// Redirect /en, /zh, etc. locale prefixes back to root
const pathname = request.nextUrl.pathname;
const locales = /^\/(en|zh|ja|ko|fr|de|es|pt|it|ru|ar|hi|th|vi|nl|sv|da|nb|fi|pl|cs|tr|uk|he|id|ms)(\/|$)/;
const locales =
/^\/(en|zh|ja|ko|fr|de|es|pt|it|ru|ar|hi|th|vi|nl|sv|da|nb|fi|pl|cs|tr|uk|he|id|ms)(\/|$)/;
if (locales.test(pathname)) {
return NextResponse.redirect(new URL("/", request.url));
}
// Generate a fresh, per-request nonce.
// Buffer.from(uuid).toString('base64') gives a URL-safe-ish base64 string
// that is unique per request and safe to embed in the CSP header value.
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const isDev = process.env.NODE_ENV === "development";
const csp = buildCsp(nonce, isDev);
// Forward the nonce to Server Components via a request header so the root
// layout can pass it to any <Script nonce={nonce}> or <style nonce={nonce}>
// elements it renders (see app/layout.tsx).
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({
request: { headers: requestHeaders },
});
response.headers.set("Content-Security-Policy", csp);
return response;
}
export const config = {