ext-apps/examples/basic-host/src/sandbox.ts
Olivier Chafik c7ef5f009b
fix: Add missing origin parameter to PostMessageTransport default constructor, verify origins in example sandbox proxy (#207)
* fix(app): add source validation to default PostMessageTransport

App.connect() now passes window.parent as both eventTarget and eventSource,
enabling source validation by default. This ensures apps only accept
messages from their parent window, preventing potential cross-app
message spoofing attacks.

Previously, the default transport only specified the target but not the
source for validation, meaning apps would accept messages from ANY window.

* fix(sandbox): add origin validation for host messages

The sandbox proxy now validates that messages from the parent window
come from the expected host origin (derived from document.referrer).
This prevents malicious pages from sending spoofed messages to the sandbox.

Changes:
- Extract EXPECTED_HOST_ORIGIN from document.referrer
- Validate event.origin against expected origin for parent messages
- Use specific origin instead of '*' when sending to parent
- Reject and log messages from unexpected origins

This addresses the TODO comment that was previously in the code.

* validate messages from app come from same origin as sandbox proxy

* fix(basic-host): be resilient to individual server connection failures

Use Promise.allSettled instead of Promise.all when connecting to
servers, so that a single server failure doesn't crash the entire UI.
Failed connections are logged as warnings but the UI continues with
the servers that connected successfully.

Also fixes video-resource-server missing server-utils.ts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-07 14:20:49 +00:00

152 lines
5.9 KiB
TypeScript

import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "../../../dist/src/types";
const ALLOWED_REFERRER_PATTERN = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
if (window.self === window.top) {
throw new Error("This file is only to be used in an iframe sandbox.");
}
if (!document.referrer) {
throw new Error("No referrer, cannot validate embedding site.");
}
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
throw new Error(
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
);
}
// Extract the expected host origin from the referrer for origin validation.
// 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;
// Security self-test: verify iframe isolation is working correctly.
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
// configuration is dangerously broken and untrusted content could escape.
try {
window.top!.alert("If you see this, the sandbox is not setup securely.");
throw "FAIL";
} catch (e) {
if (e === "FAIL") {
throw new Error("The sandbox is not setup securely.");
}
// Expected: SecurityError confirms proper sandboxing.
}
// Double-iframe sandbox architecture: THIS file is the outer sandbox proxy
// iframe on a separate origin. It creates an inner iframe for untrusted HTML
// content. Per the specification, the Host and the Sandbox MUST have different
// origins.
const inner = document.createElement("iframe");
inner.style = "width:100%; height:100%; border:none;";
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
document.body.appendChild(inner);
const RESOURCE_READY_NOTIFICATION: McpUiSandboxResourceReadyNotification["method"] =
"ui/notifications/sandbox-resource-ready";
const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
"ui/notifications/sandbox-proxy-ready";
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
// forwarding messages between:
//
// Host (parent window) ↔ Sandbox (outer frame) ↔ Guest UI (inner iframe)
//
// Reason: the parent window and inner iframe have different origins and can't
// communicate directly, so the outer iframe forwards messages in both
// directions to connect them.
//
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
// intercepted here (not relayed) because the Sandbox uses it to configure and
// load the inner iframe with the Guest UI HTML content.
// Build CSP meta tag from domains
function buildCspMetaTag(csp?: { connectDomains?: string[]; resourceDomains?: string[] }): string {
const resourceDomains = csp?.resourceDomains?.join(" ") ?? "";
const connectDomains = csp?.connectDomains?.join(" ") ?? "";
// Base CSP directives
const directives = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
`connect-src 'self' ${connectDomains}`.trim(),
"frame-src 'none'",
"object-src 'none'",
"base-uri 'self'",
];
return `<meta http-equiv="Content-Security-Policy" content="${directives.join("; ")}">`;
}
window.addEventListener("message", async (event) => {
if (event.source === window.parent) {
// Validate that messages from parent come from the expected host origin.
// This prevents malicious pages from sending messages to this sandbox.
if (event.origin !== EXPECTED_HOST_ORIGIN) {
console.error(
"[Sandbox] Rejecting message from unexpected origin:",
event.origin,
"expected:",
EXPECTED_HOST_ORIGIN
);
return;
}
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, csp } = event.data.params;
if (typeof sandbox === "string") {
inner.setAttribute("sandbox", sandbox);
}
if (typeof html === "string") {
// Inject CSP meta tag at the start of <head> if CSP is provided
console.log("[Sandbox] Received CSP:", csp);
let modifiedHtml = html;
if (csp) {
const cspMetaTag = buildCspMetaTag(csp);
console.log("[Sandbox] Injecting CSP meta tag:", cspMetaTag);
// Insert after <head> tag if present, otherwise prepend
if (modifiedHtml.includes("<head>")) {
modifiedHtml = modifiedHtml.replace("<head>", `<head>\n${cspMetaTag}`);
} else if (modifiedHtml.includes("<head ")) {
modifiedHtml = modifiedHtml.replace(/<head[^>]*>/, `$&\n${cspMetaTag}`);
} else {
modifiedHtml = cspMetaTag + modifiedHtml;
}
} else {
console.log("[Sandbox] No CSP provided, using default");
}
inner.srcdoc = modifiedHtml;
}
} else {
if (inner && inner.contentWindow) {
inner.contentWindow.postMessage(event.data, "*");
}
}
} else if (event.source === inner.contentWindow) {
if (event.origin !== OWN_ORIGIN) {
console.error(
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
event.origin,
"expected:",
OWN_ORIGIN
);
return;
}
// Relay messages from inner frame to parent window.
// Use specific origin instead of "*" to prevent message interception.
window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
}
});
// Notify the Host that the Sandbox is ready to receive Guest UI HTML.
// Use specific origin instead of "*" to ensure only the expected host receives this.
window.parent.postMessage({
jsonrpc: "2.0",
method: PROXY_READY_NOTIFICATION,
params: {},
}, EXPECTED_HOST_ORIGIN);