- Fix RESOURCE_MIME_TYPE to match spec: "text/html;profile=mcp-app" - Add CSP field to McpUiSandboxResourceReadyNotification type and schema - Update basic-host to extract and pass CSP metadata from resources - Update sandbox.ts to dynamically inject CSP meta tag into inner iframe - Remove static CSP from sandbox.html (now dynamic based on resource metadata) - Update QR server to use custom read_resource handler for _meta injection - Update requirements.txt to require mcp[cli]>=1.23.3 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
125 lines
4.9 KiB
TypeScript
125 lines
4.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.)`,
|
|
);
|
|
}
|
|
|
|
// 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) {
|
|
// NOTE: In production you'll also want to validate `event.origin` against
|
|
// your Host domain.
|
|
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) {
|
|
// Relay messages from inner frame to parent window.
|
|
window.parent.postMessage(event.data, "*");
|
|
}
|
|
});
|
|
|
|
// Notify the Host that the Sandbox is ready to receive Guest UI HTML.
|
|
window.parent.postMessage({
|
|
jsonrpc: "2.0",
|
|
method: PROXY_READY_NOTIFICATION,
|
|
params: {},
|
|
}, "*");
|