fix(canvas): replace unsafe-inline/unsafe-eval with nonce-based CSP (#450)

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 <noreply@anthropic.com>
This commit is contained in:
Molecule AI Frontend Engineer 2026-04-16 20:35:27 +00:00
parent de0344cc1e
commit d13e3935a9
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 = {