Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit ee998b2

Browse files
committed
enforce CSP on sandboxed MCP app HTML
1 parent 4b5fb9e commit ee998b2

7 files changed

Lines changed: 65 additions & 45 deletions

File tree

apps/mobile/src/features/mcp/sandbox/sandboxProxyHtml.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const sandboxProxyHtml = `<!DOCTYPE html>
4747
}
4848
4949
var inner = document.createElement("iframe");
50-
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
50+
inner.setAttribute("sandbox", "allow-scripts allow-forms");
5151
document.body.appendChild(inner);
5252
5353
function buildAllowAttribute(permissions) {
@@ -77,19 +77,15 @@ export const sandboxProxyHtml = `<!DOCTYPE html>
7777
var allowValue = buildAllowAttribute(params.permissions);
7878
if (allowValue) inner.setAttribute("allow", allowValue);
7979
80-
var doc = inner.contentDocument;
81-
if (doc) {
82-
doc.open();
83-
doc.write(params.html);
84-
doc.close();
85-
}
80+
inner.setAttribute("srcdoc", params.html);
8681
}
8782
return;
8883
}
8984
9085
// All other host messages get relayed into the inner iframe untouched.
9186
if (inner.contentWindow) {
92-
inner.contentWindow.postMessage(data, location.origin || "*");
87+
// An opaque origin serializes to "null", which postMessage rejects.
88+
inner.contentWindow.postMessage(data, "*");
9389
}
9490
};
9591

packages/shared/src/mcp-sandbox-proxy.test.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,22 @@ describe("sandboxProxyHtml", () => {
2020
);
2121
});
2222

23-
it("creates inner iframe with allow-scripts, allow-same-origin, and allow-forms sandbox", () => {
23+
it("creates inner iframe without allow-same-origin", () => {
2424
expect(sandboxProxyHtml).toContain(
25-
"allow-scripts allow-same-origin allow-forms",
25+
'inner.setAttribute("sandbox", "allow-scripts allow-forms")',
2626
);
2727
});
2828

29-
it("uses document.write to inject HTML instead of srcdoc", () => {
30-
expect(sandboxProxyHtml).toContain("doc.open()");
31-
expect(sandboxProxyHtml).toContain("doc.write(params.html)");
32-
expect(sandboxProxyHtml).toContain("doc.close()");
29+
it("uses srcdoc to inject HTML, never document.write", () => {
30+
expect(sandboxProxyHtml).toContain('inner.setAttribute("srcdoc"');
31+
expect(sandboxProxyHtml).not.toContain("doc.write(");
3332
});
3433

35-
it("uses location.origin for forwarding messages to inner iframe", () => {
36-
expect(sandboxProxyHtml).toContain("postMessage(data, location.origin)");
34+
it("forwards to the inner iframe with a wildcard target origin", () => {
35+
expect(sandboxProxyHtml).toContain('postMessage(data, "*")');
36+
expect(sandboxProxyHtml).not.toContain(
37+
"postMessage(data, location.origin)",
38+
);
3739
});
3840

3941
it("builds permission policy allow attribute with cross-origin delegation", () => {

packages/shared/src/mcp-sandbox-proxy.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,9 @@
55
*
66
* Host (renderer) → Outer iframe (sandbox proxy) → Inner iframe (MCP App)
77
*
8-
* The outer iframe is served from the host's custom protocol, giving it an
9-
* isolated origin separate from the renderer. The inner iframe uses
10-
* allow-same-origin so the proxy can write HTML via document.write() — srcdoc
11-
* creates an opaque origin that breaks WebGL canvas operations (toDataURL) and
12-
* cross-origin resource access.
13-
*
14-
* Because the proxy's origin differs from the renderer's origin, the app cannot
15-
* traverse `window.parent.parent` to access the host's DOM, storage, or cookies.
8+
* The host sandboxes the outer iframe without allow-same-origin, so both frames
9+
* get opaque origins: the app cannot reach the host's DOM, storage or cookies,
10+
* nor another app's frame.
1611
*
1712
* The HTML string itself is portable browser JavaScript with no host APIs; the
1813
* protocol that serves it is the host-specific seam.
@@ -56,7 +51,7 @@ export const sandboxProxyHtml: string = `<!DOCTYPE html>
5651
5752
var inner = document.createElement("iframe");
5853
inner.style.cssText = "width:100%; height:100%; border:none;";
59-
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
54+
inner.setAttribute("sandbox", "allow-scripts allow-forms");
6055
document.body.appendChild(inner);
6156
6257
// Build Permission Policy allow attribute from permissions object.
@@ -98,32 +93,25 @@ export const sandboxProxyHtml: string = `<!DOCTYPE html>
9893
inner.setAttribute("allow", allowValue);
9994
}
10095
101-
// Use document.write() instead of srcdoc to preserve origin.
102-
// srcdoc creates an opaque origin that breaks WebGL canvas operations
103-
// like toDataURL() and cross-origin resource access.
104-
var doc = inner.contentDocument;
105-
log("Writing HTML to inner iframe", {
106-
htmlLength: params.html.length,
107-
hasContentDocument: !!doc
96+
log("Setting inner iframe srcdoc", {
97+
htmlLength: params.html.length
10898
});
10999
110-
doc.open();
111-
doc.write(params.html);
112-
doc.close();
100+
inner.setAttribute("srcdoc", params.html);
113101
114-
log("HTML written to inner iframe");
102+
log("HTML handed to inner iframe");
115103
}
116104
} else {
117105
// Forward all other messages to inner iframe
118106
log("Forwarding host -> inner", {
119107
method: data.method,
120108
id: data.id,
121-
hasInner: !!(inner && inner.contentWindow),
122-
targetOrigin: location.origin
109+
hasInner: !!(inner && inner.contentWindow)
123110
});
124111
112+
// An opaque origin serializes to "null", which postMessage rejects.
125113
if (inner && inner.contentWindow) {
126-
inner.contentWindow.postMessage(data, location.origin);
114+
inner.contentWindow.postMessage(data, "*");
127115
}
128116
}
129117
}

packages/ui/src/features/mcp-apps/components/McpAppHost.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,8 @@ export function McpAppHost({
239239
<iframe
240240
ref={setIframeEl}
241241
src={sandboxProxyUrl}
242-
// No allow-popups: app JS is same-origin with the proxy realm, so popup
243-
// permission here would let it window.open() past the sandbox. Apps
244-
// open links via ui/open-link, which the host scheme-validates.
245-
sandbox="allow-scripts allow-same-origin allow-forms allow-presentation"
242+
// allow-same-origin would resolve this frame to the host's own origin.
243+
sandbox="allow-scripts allow-forms allow-presentation"
246244
style={{
247245
height: displayMode === "fullscreen" ? "100%" : `${iframeHeight}px`,
248246
}}

packages/ui/src/features/mcp-apps/hooks/useAppBridge.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef } from "react";
1717
import { logger } from "../../../shell/logger";
1818
import { useDraftStore } from "../../message-editor/draftStore";
1919
import type { ToolCall } from "../../sessions/types";
20+
import { applyCspToHtml } from "../utils/mcp-app-csp";
2021
import {
2122
computeContainerDimensions,
2223
INLINE_MAX_HEIGHT,
@@ -315,9 +316,8 @@ export function useAppBridge(args: UseAppBridgeArgs): UseAppBridgeReturn {
315316
await bridge.connect(transport);
316317
bridgeRef.current = bridge;
317318

318-
// Send resource to proxy
319319
await bridge.sendSandboxResourceReady({
320-
html: resource.html,
320+
html: applyCspToHtml(resource.html, resource.csp),
321321
csp: resource.csp,
322322
permissions: resource.permissions,
323323
});

packages/ui/src/features/mcp-apps/utils/mcp-app-csp.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
applyCspToHtml,
34
buildCspMetaTag,
45
buildCspString,
56
escapeAttr,
@@ -162,3 +163,26 @@ describe("buildCspMetaTag", () => {
162163
expect(tag).toMatch(/content="[^"]+"/);
163164
});
164165
});
166+
167+
describe("applyCspToHtml", () => {
168+
it("prepends the CSP meta when there is no doctype", () => {
169+
const out = applyCspToHtml("<html><body>hi</body></html>");
170+
expect(out.startsWith(buildCspMetaTag())).toBe(true);
171+
});
172+
173+
it("inserts the CSP meta after a leading doctype (no quirks mode)", () => {
174+
const out = applyCspToHtml("<!doctype html><html><head></head></html>");
175+
expect(out.startsWith("<!doctype html>")).toBe(true);
176+
expect(out).toBe(
177+
`<!doctype html>${buildCspMetaTag()}<html><head></head></html>`,
178+
);
179+
});
180+
181+
it("handles a doctype with leading whitespace and mixed case", () => {
182+
const out = applyCspToHtml(" <!DOCTYPE html>\n<html></html>");
183+
expect(out.startsWith(" <!DOCTYPE html>")).toBe(true);
184+
expect(out.indexOf("<!DOCTYPE html>")).toBeLessThan(
185+
out.indexOf(buildCspMetaTag()),
186+
);
187+
});
188+
});

packages/ui/src/features/mcp-apps/utils/mcp-app-csp.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,15 @@ export function buildCspMetaTag(csp?: McpUiResourceCsp): string {
8383
const cspString = buildCspString(csp);
8484
return `<meta http-equiv="Content-Security-Policy" content="${escapeAttr(cspString)}">`;
8585
}
86+
87+
// After any doctype, which must stay first or the frame enters quirks mode.
88+
export function applyCspToHtml(html: string, csp?: McpUiResourceCsp): string {
89+
const meta = buildCspMetaTag(csp);
90+
const doctype = html.match(/^\s*<!doctype[^>]*>/i);
91+
if (doctype) {
92+
return (
93+
html.slice(0, doctype[0].length) + meta + html.slice(doctype[0].length)
94+
);
95+
}
96+
return meta + html;
97+
}

0 commit comments

Comments
 (0)