From d13e3935a91dd0fdbbe812ef72c7272bb7c8f67c Mon Sep 17 00:00:00 2001 From: Molecule AI Frontend Engineer Date: Thu, 16 Apr 2026 20:35:27 +0000 Subject: [PATCH] fix(canvas): replace unsafe-inline/unsafe-eval with nonce-based CSP (#450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes 'unsafe-inline' and 'unsafe-eval' from script-src in the production Content-Security-Policy, replacing them with a per-request nonce + 'strict-dynamic'. This closes the XSS gap reported in #450 where the CSP header gave false assurance. Key decisions: - 'strict-dynamic' propagates nonce trust to Next.js dynamic chunk imports — no need to enumerate every chunk URL - style-src retains 'unsafe-inline': React Flow writes inline style="" attributes for node positioning which cannot be nonce'd, and CSS injection is accepted as significantly lower risk than script injection - Dev mode keeps the permissive policy so HMR/fast-refresh keep working - buildCsp() is exported for unit testing (21 tests added) Additional hardening in production CSP: object-src 'none', base-uri 'self', frame-ancestors 'none', upgrade-insecure-requests, connect-src limited to wss: (not ws:) Co-Authored-By: Claude Sonnet 4.6 --- canvas/src/__tests__/csp-nonce.test.ts | 126 +++++++++++++++++++++++++ canvas/src/middleware.ts | 78 ++++++++++++++- 2 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 canvas/src/__tests__/csp-nonce.test.ts diff --git a/canvas/src/__tests__/csp-nonce.test.ts b/canvas/src/__tests__/csp-nonce.test.ts new file mode 100644 index 00000000..5ba50b6e --- /dev/null +++ b/canvas/src/__tests__/csp-nonce.test.ts @@ -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:"); + }); + } +}); diff --git a/canvas/src/middleware.ts b/canvas/src/middleware.ts index cf5f828e..bff29c1f 100644 --- a/canvas/src/middleware.ts +++ b/canvas/src/middleware.ts @@ -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