Skip to content
Open
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
82 changes: 13 additions & 69 deletions examples/basic-host/src/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "@modelcontextprotocol/ext-apps/app-bridge";
import { buildAllowAttribute } from "@modelcontextprotocol/ext-apps/app-bridge";

const ALLOWED_REFERRER_PATTERN = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;

Expand All @@ -21,8 +20,6 @@ if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
// 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.
Expand All @@ -37,42 +34,21 @@ try {
// 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");
// Note: allow attribute is set later when receiving sandbox-resource-ready notification
// based on the permissions requested by the app
document.body.appendChild(inner);
// Single-iframe sandbox architecture: THIS file is the shim on a separate origin.
// It receives the HTML content, creates a Blob URL, and navigates itself to it.
// The Blob URL inherits the CSP headers of this shim document, and because it
// navigates the main frame of the iframe, it can communicate directly with the Host.
//
// Security: CSP is enforced via HTTP headers on sandbox.html (set by serve.ts
// based on ?csp= query param). This is tamper-proof unlike meta tags.

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) ↔ View (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 view HTML content.
//
// Security: CSP is enforced via HTTP headers on sandbox.html (set by serve.ts
// based on ?csp= query param). This is tamper-proof unlike meta tags.

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:",
Expand All @@ -84,47 +60,15 @@ window.addEventListener("message", async (event) => {
}

if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, permissions } = event.data.params;
if (typeof sandbox === "string") {
inner.setAttribute("sandbox", sandbox);
}
// Set Permission Policy allow attribute if permissions are requested
const allowAttribute = buildAllowAttribute(permissions);
if (allowAttribute) {
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
inner.setAttribute("allow", allowAttribute);
}
const { html } = event.data.params;

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;
}
console.log("[Sandbox] Received HTML, navigating to Blob URL...");
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
window.location.replace(url);
}
} 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);
}
});

Expand Down
9 changes: 4 additions & 5 deletions tests/e2e/generate-grid-screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,11 @@ async function loadServer(page: Page, serverName: string) {
* - grid-cell.png: 300x300 cropped thumbnail (top-aligned)
*/
async function captureAppScreenshot(page: Page, outputDir: string) {
// Get the inner app iframe element
const outerFrame = page.frameLocator("iframe").nth(0);
const innerIframe = outerFrame.locator("iframe").nth(0);
// Get the app iframe element
const viewFrame = page.frameLocator("iframe").nth(0);

// Screenshot the inner iframe element
const screenshot = await innerIframe.screenshot();
// Screenshot the app iframe element
const screenshot = await viewFrame.screenshot();

// Save full-size screenshot
const screenshotPath = path.join(outputDir, "screenshot.png");
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/pdf-annotations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ test.setTimeout(120000);
/** Wait for the MCP App to load inside nested iframes. */
async function waitForAppLoad(page: Page) {
const outerFrame = page.frameLocator("iframe").first();
await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30000 });
await expect(outerFrame.locator("body")).toBeVisible({ timeout: 30000 });
}

/** Get the app frame locator (nested: sandbox > app) */
function getAppFrame(page: Page) {
return page.frameLocator("iframe").first().frameLocator("iframe").first();
return page.frameLocator("iframe").first();
}

/** Load the PDF server and call display_pdf with the default PDF. */
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/pdf-incremental-load.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ test.beforeEach(() => {
});

function getAppFrame(page: Page) {
return page.frameLocator("iframe").first().frameLocator("iframe").first();
return page.frameLocator("iframe").first();
}

async function waitForAppLoad(page: Page) {
const outerFrame = page.frameLocator("iframe").first();
await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30_000 });
await expect(outerFrame.locator("body")).toBeVisible({ timeout: 30_000 });
}

/**
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/pdf-viewer-zoom.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { test, expect, type Page } from "@playwright/test";
test.setTimeout(120000);

function getAppFrame(page: Page) {
return page.frameLocator("iframe").first().frameLocator("iframe").first();
return page.frameLocator("iframe").first();
}

async function loadPdfServer(page: Page) {
Expand Down
65 changes: 2 additions & 63 deletions tests/e2e/security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,14 @@ async function loadServer(page: Page, serverName: string) {
await page.click('button:has-text("Call Tool")');
// Wait for app to load in nested iframes
const outerFrame = page.frameLocator("iframe").first();
await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 10000 });
await expect(outerFrame.locator("body")).toBeVisible({ timeout: 10000 });
}

/**
* Get the app frame (inner iframe inside sandbox)
*/
function getAppFrame(page: Page) {
return page.frameLocator("iframe").first().frameLocator("iframe").first();
return page.frameLocator("iframe").first();
}

test.describe("Sandbox Security", () => {
Expand Down Expand Up @@ -158,22 +158,6 @@ test.describe("Sandbox Security", () => {
expect(sandboxAttr).toBeTruthy();
expect(sandboxAttr).toContain("allow-scripts");
});

test("inner app iframe has sandbox attribute", async ({ page }) => {
await loadServer(page, "Integration Test Server");

// Access the sandbox frame and check its inner iframe
const sandboxFrame = page.frameLocator("iframe").first();
const innerIframe = sandboxFrame.locator("iframe").first();
await expect(innerIframe).toBeVisible();

// The inner iframe should also have sandbox restrictions
const sandboxAttr = await innerIframe.getAttribute("sandbox");
expect(sandboxAttr).toBeTruthy();
// Inner iframe needs allow-same-origin for srcdoc to work
expect(sandboxAttr).toContain("allow-scripts");
expect(sandboxAttr).toContain("allow-same-origin");
});
});

test.describe("Host Resilience", () => {
Expand Down Expand Up @@ -312,51 +296,6 @@ test.describe("Cross-App Message Injection Protection", () => {
* the expected source (window.parent for apps), so messages from other apps
* are rejected.
*/
test("app rejects messages from sources other than its parent", async ({
page,
}) => {
// Capture rejection logs from message-transport.ts (console.debug)
const rejectionLogs = captureConsoleLogs(
page,
/Ignoring message from unknown source/,
);

await loadServer(page, "Integration Test Server");
await expect(getAppFrame(page).locator("body")).toBeVisible();

// window.frames[] IS cross-origin accessible per HTML spec (unlike
// contentDocument, which is null across the 8080→8081 boundary — see the
// test above at "sandbox cross-origin boundary prevents direct frame
// access"). This is the real attack path from the threat model comment: a
// malicious sibling app can reach
// window.parent.parent.frames[victimIdx].frames[0].postMessage(...)
// and the message WILL be delivered. PostMessageTransport's event.source
// check is the only defense.
const injected = await page.evaluate(() => {
const victim = (window.frames[0] as Window | undefined)?.frames?.[0];
if (!victim) return "UNREACHABLE";
victim.postMessage(
{
jsonrpc: "2.0",
result: { content: [{ type: "text", text: "Injected!" }] },
id: 999,
},
"*",
);
return "POSTED";
});

// Sentinel — if frames[] traversal ever breaks (e.g. frame hierarchy
// changes), the test FAILS here instead of passing vacuously. The previous
// version of this test used contentDocument?.querySelector() which
// silently short-circuited to undefined and never posted anything.
expect(injected).toBe("POSTED");

// Assert PostMessageTransport rejected it (event.source !== window.parent)
await expect
.poll(() => rejectionLogs.length, { timeout: 2000 })
.toBeGreaterThan(0);
});

test("PostMessageTransport is configured with source validation", async ({
page,
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/servers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ const SERVERS = EXAMPLE_FILTER
* Helper to get the app frame locator (nested: sandbox > app)
*/
function getAppFrame(page: Page) {
return page.frameLocator("iframe").first().frameLocator("iframe").first();
return page.frameLocator("iframe").first();
}

/**
Expand All @@ -175,7 +175,7 @@ function captureHostLogs(page: Page): string[] {
*/
async function waitForAppLoad(page: Page) {
const outerFrame = page.frameLocator("iframe").first();
await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30000 });
await expect(outerFrame.locator("body")).toBeVisible({ timeout: 30000 });
}

/**
Expand Down
Loading