molecule-core/canvas/src/middleware.ts
Molecule AI Frontend Engineer 81c26b9fdd 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>
2026-04-16 20:35:27 +00:00

92 lines
3.5 KiB
TypeScript

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)(\/|$)/;
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 = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};