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:
parent
ca09c415bf
commit
b4c6019d47
6 changed files with 116 additions and 66 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
80
examples/simple-host/serve.ts
Normal file
80
examples/simple-host/serve.ts
Normal 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");
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
"*",
|
||||
|
|
|
|||
Loading…
Reference in a new issue