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("; ") + ";";