From 3405a15f5c151e6202f4d299831e388935921fc2 Mon Sep 17 00:00:00 2001 From: cecilia-marques Date: Tue, 6 Oct 2026 23:12:29 -0300 Subject: [PATCH] fix(mcp-apps): authorize wss:// for app connect domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An MCP app that declares an https connect domain (resource `_meta.ui.csp. connectDomains`) still can't open a WebSocket to it: the injected `connect-src https://host` does NOT authorize `wss://host` — CSP does not derive the WebSocket scheme from an https: source (verified in Chrome: the socket is blocked inside the sandboxed iframe, while identical from the top document it connects). Apps can't work around it — validateDomains only accepts http(s). The injector now adds each connect domain's WebSocket-scheme equivalent (https→wss, http→ws), originals first so fetch/XHR matching and existing output are unchanged. This unblocks realtime (Yjs) co-editing in MCP apps. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/mcp-apps/csp-injector.test.ts | 11 +++++++++++ packages/shared/src/mcp-apps/csp-injector.ts | 13 ++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/mcp-apps/csp-injector.test.ts b/packages/shared/src/mcp-apps/csp-injector.test.ts index 76a5ed1fc8..f2291ddcbd 100644 --- a/packages/shared/src/mcp-apps/csp-injector.test.ts +++ b/packages/shared/src/mcp-apps/csp-injector.test.ts @@ -123,6 +123,17 @@ describe("injectCSP with resourceCsp", () => { ); }); + it("authorizes the WebSocket scheme for each connect domain", () => { + // CSP does not derive wss: from an https: source, so the injector adds it. + const result = injectCSP(html, { + resourceCsp: { + connectDomains: ["https://api.example.com", "http://local.test:3001"], + }, + }); + expect(result).toContain("wss://api.example.com"); + expect(result).toContain("ws://local.test:3001"); + }); + it("adds frameDomains to frame-src", () => { const result = injectCSP(html, { resourceCsp: { diff --git a/packages/shared/src/mcp-apps/csp-injector.ts b/packages/shared/src/mcp-apps/csp-injector.ts index 037c3bce01..269f70ebf1 100644 --- a/packages/shared/src/mcp-apps/csp-injector.ts +++ b/packages/shared/src/mcp-apps/csp-injector.ts @@ -73,6 +73,17 @@ function validateDomains(domains: string[] | undefined): string[] { return domains.filter((d) => DOMAIN_RE.test(d)); } +/** + * CSP `connect-src` does not treat an `https:` source as covering `wss:` (a + * WebSocket to a host listed only as `https://host` is blocked — verified in + * Chrome). So for each connect domain also authorize its WebSocket scheme: + * `https://host` → `wss://host`, `http://host` → `ws://host`. Originals are kept + * first so plain fetch/XHR still match and existing output is unchanged up front. + */ +function withWebSocketSchemes(domains: string[]): string[] { + return [...domains, ...domains.map((d) => d.replace(/^http/, "ws"))]; +} + function buildCSPPolicy(options: CSPInjectorOptions): string { if (options.csp) return options.csp; @@ -116,7 +127,7 @@ function buildCSPPolicy(options: CSPInjectorOptions): string { : "media-src * data: blob:", hasResourceDomains ? `font-src data: ${rd}` : "font-src data:", hasConnectDomains - ? `connect-src ${connectDomains.join(" ")}` + ? `connect-src ${withWebSocketSchemes(connectDomains).join(" ")}` : "connect-src 'none'", hasFrameDomains ? `frame-src ${frameDomains.join(" ")}`