diff --git a/examples/basic-host/src/sandbox.ts b/examples/basic-host/src/sandbox.ts index 6f1dd93d7..c88b85805 100644 --- a/examples/basic-host/src/sandbox.ts +++ b/examples/basic-host/src/sandbox.ts @@ -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)(:|\/|$)/; @@ -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. @@ -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:", @@ -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); } }); diff --git a/tests/e2e/generate-grid-screenshots.spec.ts b/tests/e2e/generate-grid-screenshots.spec.ts index bbe895927..f3346e446 100644 --- a/tests/e2e/generate-grid-screenshots.spec.ts +++ b/tests/e2e/generate-grid-screenshots.spec.ts @@ -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"); diff --git a/tests/e2e/pdf-annotations.spec.ts b/tests/e2e/pdf-annotations.spec.ts index af67bb790..a17758088 100644 --- a/tests/e2e/pdf-annotations.spec.ts +++ b/tests/e2e/pdf-annotations.spec.ts @@ -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. */ diff --git a/tests/e2e/pdf-incremental-load.spec.ts b/tests/e2e/pdf-incremental-load.spec.ts index 52a99ee9a..66bee9f6b 100644 --- a/tests/e2e/pdf-incremental-load.spec.ts +++ b/tests/e2e/pdf-incremental-load.spec.ts @@ -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 }); } /** diff --git a/tests/e2e/pdf-viewer-zoom.spec.ts b/tests/e2e/pdf-viewer-zoom.spec.ts index 574809bd8..60bcd7e1b 100644 --- a/tests/e2e/pdf-viewer-zoom.spec.ts +++ b/tests/e2e/pdf-viewer-zoom.spec.ts @@ -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) { diff --git a/tests/e2e/security.spec.ts b/tests/e2e/security.spec.ts index 8fb94e3f9..3f47c1298 100644 --- a/tests/e2e/security.spec.ts +++ b/tests/e2e/security.spec.ts @@ -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", () => { @@ -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", () => { @@ -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, diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts index a3c30d8a8..06eb07a05 100644 --- a/tests/e2e/servers.spec.ts +++ b/tests/e2e/servers.spec.ts @@ -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(); } /** @@ -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 }); } /**