Merge pull request #1093 from Molecule-AI/fix/csp-allow-platform-url

fix(canvas): include PLATFORM_URL origin in CSP connect-src
This commit is contained in:
Hongming Wang 2026-04-20 12:55:09 -07:00 committed by GitHub
commit dae3eb931d
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("; ") + ";";