Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions clients/web/server/sandbox-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ export function createSandboxController(
let server: Server | null = null;
let sandboxUrl: string | null = null;

// Defense-in-depth for the proxy page itself. Only `frame-ancestors` is set
// here — fetch directives (`default-src`, `connect-src`, etc.) are
// deliberately omitted because a `srcdoc` iframe clones its embedder's CSP
// policy container: any fetch directive on this header would be inherited by
// the inner app document and, since multiple CSPs intersect, would override
// the per-app `connect-src`/`img-src` allowlists the host bakes into the
// wrapped HTML (see src/lib/sandbox-csp.ts). The opaque-origin sandbox on
// the inner frame is the structural boundary; `frame-ancestors` ensures the
// proxy can only be embedded by the local inspector itself.
const SANDBOX_PROXY_CSP =
"frame-ancestors http://127.0.0.1:* http://localhost:*";

let sandboxHtml: string;
try {
const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html");
Expand Down Expand Up @@ -91,6 +103,7 @@ export function createSandboxController(
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, no-cache, must-revalidate",
Pragma: "no-cache",
"Content-Security-Policy": SANDBOX_PROXY_CSP,
});
res.end(sandboxHtml);
});
Expand Down
2 changes: 1 addition & 1 deletion clients/web/src/lib/sandbox-csp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ describe("wrapSandboxedHtml", () => {
});

it("does not introduce a host-authored <iframe>, so the widget's window.parent remains the sandbox proxy", () => {
// The proxy writes the wrap output into the inner iframe via document.write.
// The proxy assigns the wrap output to the inner iframe's srcdoc.
// If the wrap inserted its own <iframe>, the widget's window.parent would
// point at the wrap's intermediate frame instead of the proxy, breaking the
// postMessage relay (and the proxy's event.source === inner.contentWindow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ describe("createSandboxController", () => {
const res = await fetch(url);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/html");
// Defense-in-depth CSP on the proxy itself: only frame-ancestors, so the
// proxy can only be embedded by the local inspector. Fetch directives are
// deliberately ABSENT — a srcdoc iframe inherits its embedder's policy
// container, so any default-src/connect-src here would intersect with and
// override the per-app CSP baked into the inner document.
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).toContain("frame-ancestors http://127.0.0.1:*");
expect(csp).not.toContain("default-src");
expect(csp).not.toContain("connect-src");
const body = await res.text();
// Either the real proxy file (sandbox-resource-ready) or the fallback
// "Sandbox not loaded" string, depending on whether static/ resolves.
Expand Down
100 changes: 67 additions & 33 deletions clients/web/static/sandbox_proxy.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,25 @@
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<!-- Isolation model: the iframe `sandbox` attribute set on the inner
frame below ("allow-scripts allow-same-origin allow-forms") is the
only enforced isolation boundary. Plus the parent-origin allowlist
(ALLOWED_REFERRER_PATTERN + EXPECTED_HOST_ORIGIN check on incoming
messages). A CSP header is intentionally NOT set by the sandbox
controller — if you need one, send it from server.ts when serving
this file. -->
<!-- Isolation model: the inner iframe is sandboxed WITHOUT
`allow-same-origin`, so the untrusted app runs under an opaque
("null") origin. That is the structural boundary — the app cannot
touch this proxy's DOM, window, or cookies, so it cannot bypass its
own CSP by executing in the parent's realm. The proxy itself is
additionally served with a restrictive CSP header (see
server/sandbox-controller.ts) so any future regression that re-grants
same-origin still gains nothing. The default sandbox value
("allow-scripts allow-forms") may be narrowed per app via the
resource-ready message's `sandbox` field; `allow-same-origin` is
always stripped from a server-supplied value.

Network/resource egress from the inner document is constrained by a
Content-Security-Policy that the HOST builds and bakes into the
delivered HTML before it reaches this proxy (see
src/lib/sandbox-csp.ts). The proxy assigns that pre-wrapped document
to `srcdoc` verbatim — it never parses or injects into the untrusted
bytes — so the CSP `<meta>` is guaranteed to be the first `<head>`
child of the inner document. -->
<title>MCP-UI Proxy</title>
<style>
html,
Expand Down Expand Up @@ -73,7 +85,13 @@
// This is the origin we expect all parent messages to come from.
const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;

const OWN_ORIGIN = new URL(window.location.href).origin;
// The inner iframe is sandboxed without allow-same-origin, so its
// document has an opaque origin and every postMessage from it arrives
// with event.origin === "null" (the literal string). That value is
// checked below as a defense-in-depth assertion; the actual identity
// proof is `event.source === inner.contentWindow`, which a sibling
// frame cannot forge.
const INNER_ORIGIN = "null";

// Security self-test: verify iframe isolation is working correctly.
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
Expand All @@ -97,10 +115,7 @@
// origins.
const inner = document.createElement("iframe");
inner.style = "width:100%; height:100%; border:none;";
inner.setAttribute(
"sandbox",
"allow-scripts allow-same-origin allow-forms",
);
inner.setAttribute("sandbox", "allow-scripts allow-forms");
// Note: allow attribute is set later when receiving sandbox-resource-ready notification
// based on the permissions requested by the app
document.body.appendChild(inner);
Expand All @@ -122,9 +137,11 @@
// intercepted here (not relayed) because the Sandbox uses it to configure and
// load the inner iframe with the view HTML content.
//
// Security: isolation comes from the iframe `sandbox` attribute and
// the origin allowlist above. No CSP header is set on this page (see
// the head comment); the sandbox attribute is the enforced boundary.
// Security: isolation comes from the iframe `sandbox` attribute and the
// origin allowlist above. The CSP for the inner document is built and
// wrapped by the host before the HTML reaches this proxy (see head
// comment); this script writes that pre-wrapped document verbatim and
// never parses or injects into the untrusted bytes.

window.addEventListener("message", async (event) => {
if (event.source === window.parent) {
Expand All @@ -143,7 +160,24 @@
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, permissions } = event.data.params;
if (typeof sandbox === "string") {
inner.setAttribute("sandbox", sandbox);
// Never grant allow-same-origin even if the server asks for it —
// that would let the untrusted app reach this proxy's DOM and
// bypass its own CSP via window.parent. Sandbox tokens are
// ASCII case-insensitive, so compare lowercased.
const safeTokens = sandbox
.split(/\s+/)
.filter((t) => t && t.toLowerCase() !== "allow-same-origin");
// Fail-closed but noisy: a server-supplied sandbox that omits
// allow-scripts leaves the inner frame unable to run the app.
// That's safe, but silently bricks the widget — log it so a
// developer can see why their app never started.
if (!safeTokens.some((t) => t.toLowerCase() === "allow-scripts")) {
console.warn(
"[Sandbox] server-supplied sandbox has no allow-scripts; the app will not execute:",
safeTokens.join(" "),
);
}
inner.setAttribute("sandbox", safeTokens.join(" "));
}
// Set Permission Policy allow attribute if permissions are requested
const allowAttribute = buildAllowAttribute(permissions);
Expand All @@ -152,33 +186,33 @@
inner.setAttribute("allow", allowAttribute);
}
if (typeof html === "string") {
// Use document.write instead of srcdoc (which the CesiumJS Map won't work with)
const doc =
inner.contentDocument || inner.contentWindow?.document;
if (doc) {
doc.open();
doc.write(html);
doc.close();
} else {
// Fallback to srcdoc if document is not accessible
console.warn(
"[Sandbox] document.write not available, falling back to srcdoc",
);
inner.srcdoc = html;
}
// The host has already wrapped the app's HTML in a fixed shell
// whose first <head> child is the CSP <meta>; assign it verbatim.
// srcdoc is the only delivery path that works without
// allow-same-origin (document.open/write would require reaching
// into the inner contentDocument across the opaque-origin
// boundary). Known trade-off: a small set of libraries (e.g.
// CesiumJS Map) don't initialise correctly under srcdoc — that
// is accepted in exchange for the isolation guarantee.
inner.srcdoc = html;
}
} else {
if (inner && inner.contentWindow) {
inner.contentWindow.postMessage(event.data, "*");
}
}
} else if (event.source === inner.contentWindow) {
if (event.origin !== OWN_ORIGIN) {
// `event.source === inner.contentWindow` is the unforgeable identity
// check — only the actual inner frame can satisfy it. The opaque
// sandbox means its origin serialises as the literal string "null";
// assert that as defense-in-depth so a future change that
// accidentally re-grants allow-same-origin is caught here.
if (event.origin !== INNER_ORIGIN) {
console.error(
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
event.origin,
"expected:",
OWN_ORIGIN,
"expected opaque:",
INNER_ORIGIN,
);
return;
}
Expand Down
Loading