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:
parent
de0344cc1e
commit
d13e3935a9
126
canvas/src/__tests__/csp-nonce.test.ts
Normal file
126
canvas/src/__tests__/csp-nonce.test.ts
Normal 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:");
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user