From d069231d0bdcd247ba5f9eab54b99dc1603c0f98 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 20 Apr 2026 12:55:03 -0700 Subject: [PATCH] fix(canvas): include NEXT_PUBLIC_PLATFORM_URL in CSP connect-src MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- canvas/src/__tests__/csp-nonce.test.ts | 23 +++++++++++++++++++++-- canvas/src/middleware.ts | 22 +++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/canvas/src/__tests__/csp-nonce.test.ts b/canvas/src/__tests__/csp-nonce.test.ts index 5ba50b6e..a76235aa 100644 --- a/canvas/src/__tests__/csp-nonce.test.ts +++ b/canvas/src/__tests__/csp-nonce.test.ts @@ -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:|\*/); }); }); diff --git a/canvas/src/middleware.ts b/canvas/src/middleware.ts index 37463768..9903a443 100644 --- a/canvas/src/middleware.ts +++ b/canvas/src/middleware.ts @@ -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("; ") + ";";