example-host: fix sandboxing setup (#21)

* example-host: prevent accidental non-iframing of sandbox (or iframing in prod)

* fix sandbox isolation + "test in prod"

* Update package.json

* Update sandbox.ts
This commit is contained in:
Olivier Chafik 2025-11-25 02:45:55 +00:00 • committed by GitHub
commit b4c6019d47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 116 additions and 66 deletions

View file

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"start": "NODE_ENV=development npm run build && concurrently 'npm run start:server'",
"start:server": "bun server.ts",
"start:server": "bun serve.ts",
"build": "concurrently 'INPUT=example-host-vanilla.html vite build' 'INPUT=example-host-react.html vite build' 'INPUT=sandbox.html vite build'"
},
"dependencies": {

View file

@ -0,0 +1,80 @@
#!/usr/bin/env npx tsx
/**
* HTTP servers for the MCP UI example:
* - Host server (port 8080): serves host HTML files (React and Vanilla examples)
* - Sandbox server (port 8081): serves sandbox.html with permissive CSP
*
* Running on separate ports ensures proper origin isolation for security.
*/
import express from "express";
import cors from "cors";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const HOST_PORT = parseInt(process.env.HOST_PORT || "8080", 10);
const SANDBOX_PORT = parseInt(process.env.SANDBOX_PORT || "8081", 10);
const DIRECTORY = join(__dirname, "dist");
// ============ Host Server (port 8080) ============
const hostApp = express();
hostApp.use(cors());
// Exclude sandbox.html from host server
hostApp.use((req, res, next) => {
if (req.path === "/sandbox.html") {
res.status(404).send("Sandbox is served on a different port");
return;
}
next();
});
hostApp.use(express.static(DIRECTORY));
hostApp.get("/", (_req, res) => {
res.redirect("/example-host-react.html");
});
// ============ Sandbox Server (port 8081) ============
const sandboxApp = express();
sandboxApp.use(cors());
// Permissive CSP for sandbox content
sandboxApp.use((_req, res, next) => {
const csp = [
"default-src 'self'",
"img-src * data: blob: 'unsafe-inline'",
"style-src * blob: data: 'unsafe-inline'",
"script-src * blob: data: 'unsafe-inline' 'unsafe-eval'",
"connect-src *",
"font-src * blob: data:",
"media-src * blob: data:",
"frame-src * blob: data:",
].join("; ");
res.setHeader("Content-Security-Policy", csp);
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
next();
});
sandboxApp.get(["/", "/sandbox.html"], (_req, res) => {
res.sendFile(join(DIRECTORY, "sandbox.html"));
});
sandboxApp.use((_req, res) => {
res.status(404).send("Only sandbox.html is served on this port");
});
// ============ Start both servers ============
hostApp.listen(HOST_PORT, () => {
console.log(`Host server: http://localhost:${HOST_PORT}`);
});
sandboxApp.listen(SANDBOX_PORT, () => {
console.log(`Sandbox server: http://localhost:${SANDBOX_PORT}`);
console.log("\nPress Ctrl+C to stop\n");
});

View file

@ -1,58 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Simple HTTP server to serve the host and sandbox html files with appropriate
* Content Security Policy (CSP) headers.
*/
import express from "express";
import cors from "cors";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PORT = parseInt(process.env.PORT || "8080", 10);
const DIRECTORY = join(__dirname, "dist");
const app = express();
// CORS middleware for all routes
app.use(cors());
// Custom middleware for sandbox.html and root
app.use((req, res, next) => {
if (req.path === "/sandbox.html" || req.path === "/") {
// Permissive CSP to allow external resources (images, styles, scripts)
const csp = [
"default-src 'self'",
"img-src * data: blob: 'unsafe-inline'",
"style-src * blob: data: 'unsafe-inline'",
"script-src * blob: data: 'unsafe-inline' 'unsafe-eval'",
"connect-src *",
"font-src * blob: data:",
"media-src * blob: data:",
"frame-src * blob: data:",
].join("; ");
res.setHeader("Content-Security-Policy", csp);
// Disable caching to ensure fresh content on every request
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
}
next();
});
// Serve static files from dist directory
app.use(express.static(DIRECTORY));
// Redirect root to example-host.html
app.get("/", (_req, res) => {
res.redirect("/example-host-react.html");
});
app.listen(PORT, () => {
console.log(`Server running on: http://localhost:${PORT}`);
console.log("Press Ctrl+C to stop the server\n");
});

View file

@ -8,7 +8,7 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { AppRenderer, AppRendererProps } from "../src/AppRenderer";
import { AppBridge } from "../../../dist/src/app-bridge";
const SANDBOX_PROXY_URL = URL.parse("/sandbox.html", location.href)!;
const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html");
/**
* Example React application demonstrating the AppRenderer component.

View file

@ -18,7 +18,7 @@ import {
McpUiSizeChangeNotificationSchema,
} from "@modelcontextprotocol/ext-apps";
const SANDBOX_PROXY_URL = URL.parse("/sandbox.html", location.href)!;
const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html");
window.addEventListener("load", async () => {
const client = new Client({

View file

@ -1,16 +1,43 @@
// Double-iframe raw HTML mode (HTML sent via postMessage)
import type {
McpUiSandboxProxyReadyNotification,
McpUiSandboxResourceReadyNotification,
} from "../../../dist/src/types";
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(/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/)) {
throw new Error(
`Embedding domain not allowed in referrer ${document.referrer} (update the validation logic to allow your domain)`,
);
}
// Try and break out of this iframe
try {
window.top!.alert("If you see this, the sandbox is not setup securely.");
throw new Error(
"Managed to break out of iframe, the sandbox is not setup securely.",
);
} catch (e) {
// Ignore
}
const inner = document.createElement("iframe");
inner.style = "width:100%; height:100%; border:none;";
// sandbox will be set from postMessage payload; default minimal before html arrives
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
document.body.appendChild(inner);
// Wait for HTML content from parent
window.addEventListener("message", async (event) => {
// Note: in production you'll also want to validate event.origin against your outer domain.
if (event.source === window.parent) {
if (
event.data &&
event.data.method === "ui/notifications/sandbox-resource-ready"
event.data.method ===
("ui/notifications/sandbox-resource-ready" as McpUiSandboxResourceReadyNotification["method"])
) {
const { html, sandbox } = event.data.params;
if (typeof sandbox === "string") {
@ -34,7 +61,8 @@ window.addEventListener("message", async (event) => {
window.parent.postMessage(
{
jsonrpc: "2.0",
method: "ui/notifications/sandbox-proxy-ready",
method:
"ui/notifications/sandbox-proxy-ready" as McpUiSandboxProxyReadyNotification["method"],
params: {},
},
"*",