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:
parent
73a43c2a9a
commit
d069231d0b
@ -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:|\*/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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("; ") + ";";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user