fix(canvas): include NEXT_PUBLIC_PLATFORM_URL in CSP connect-src

Tenant page loads were blocked by:

  Refused to connect to 'https://api.moleculesai.app/cp/auth/me'
  because it violates the document's Content Security Policy.

CSP had `connect-src 'self' wss:` — fine for same-origin + any wss,
but browser refuses cross-origin HTTPS fetches that aren't listed.
PLATFORM_URL (baked from NEXT_PUBLIC_PLATFORM_URL, which is the CP
origin on SaaS tenants) needs to be explicit.

Fix: middleware reads NEXT_PUBLIC_PLATFORM_URL at build/runtime
and adds both the https and wss siblings to connect-src. Self-
hosted deploys that override the build-arg automatically get a
matching CSP — no hardcoded hostname.

Test added: buildCsp includes NEXT_PUBLIC_PLATFORM_URL origin in
connect-src when set. Also loosens the dev `ws:` assertion since
dev uses `connect-src *` which subsumes ws (pre-existing behavior,
test was stale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-20 12:55:03 -07:00
parent 73a43c2a9a
commit d069231d0b
2 changed files with 42 additions and 3 deletions

View File

@ -58,6 +58,21 @@ describe("buildCsp — production", () => {
expect(connectSrc).toContain("wss:");
});
it("adds NEXT_PUBLIC_PLATFORM_URL origin to connect-src (CP cross-origin fetches)", () => {
// Canvas calls fetch(PLATFORM_URL + "/cp/auth/me") etc. from the
// browser. The browser blocks cross-origin fetches that aren't in
// connect-src, even with CORS headers set server-side. Whitelisting
// the origin is what makes fresh login + session refresh work on
// tenant subdomains after they were broken pre-2026-04-20 by a CSP
// that only allowed 'self' and wss:.
process.env.NEXT_PUBLIC_PLATFORM_URL = "https://api.example.com";
const built = buildCsp(TEST_NONCE, false);
const connectSrc = built.match(/connect-src[^;]*/)?.[0] ?? "";
expect(connectSrc).toContain("https://api.example.com");
expect(connectSrc).toContain("wss://api.example.com");
delete process.env.NEXT_PUBLIC_PLATFORM_URL;
});
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
@ -95,9 +110,13 @@ describe("buildCsp — development", () => {
expect(scriptSrc()).toContain("'unsafe-eval'");
});
it("allows ws: in connect-src (HMR WebSocket uses plain ws://)", () => {
it("permits ws:// in connect-src (HMR WebSocket uses plain ws://)", () => {
const connectSrc = csp.match(/connect-src[^;]*/)?.[0] ?? "";
expect(connectSrc).toContain("ws:");
// Dev uses `connect-src *` (fully permissive) which subsumes ws:.
// Accept either the literal `ws:` scheme wildcard OR the blanket
// `*` since both let HMR through. Prod tests still enforce `ws:`
// is NOT present.
expect(connectSrc).toMatch(/\bws:|\*/);
});
});

View File

@ -35,6 +35,26 @@ export function buildCsp(nonce: string, isDev: boolean): string {
].join("; ") + ";";
}
// Canvas makes cross-origin fetches to the control plane for
// /cp/auth/*, /cp/orgs/*, /cp/billing/* — PLATFORM_URL points at
// it (baked in at build time via NEXT_PUBLIC_PLATFORM_URL). CSP
// has to whitelist that origin in connect-src or the browser
// refuses the fetch with "Refused to connect because it violates
// the document's Content Security Policy."
//
// Self-hosted deployments override PLATFORM_URL at build time and
// the CSP adjusts automatically — no hardcoded hostname here.
const platformURL = process.env.NEXT_PUBLIC_PLATFORM_URL ?? "";
const connectSrcParts = ["'self'", "wss:"];
if (platformURL) {
connectSrcParts.push(platformURL);
// Also allow the wss:// sibling of PLATFORM_URL explicitly.
// `wss:` scheme-wildcard covers it today but making the exact
// origin explicit survives a future CSP tightening without
// silently breaking auth.
connectSrcParts.push(platformURL.replace(/^http/, "ws"));
}
return [
"default-src 'self'",
// Nonce-based: no unsafe-inline, no unsafe-eval.
@ -49,7 +69,7 @@ export function buildCsp(nonce: string, isDev: boolean): string {
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"connect-src 'self' wss:",
`connect-src ${connectSrcParts.join(" ")}`,
"worker-src 'self' blob:",
"upgrade-insecure-requests",
].join("; ") + ";";