Skip to content

Commit 8b09f42

Browse files
ochafikclaude
andauthored
fix: proper CSP handling in basic-host sandboxing (HTTP headers, worker-src, document.write) (#234)
* fix: Move CSP to HTTP headers + add worker-src, frameDomains, baseUriDomains, permissions Security improvements: - CSP is now set via HTTP headers in serve.ts instead of meta tags (meta tag CSP can be tampered with by same-origin content) - CSP passed as query param to sandbox.html for header-based enforcement New CSP/permissions features (borrowed from PR #158): - frameDomains: control frame-src directive for nested iframes - baseUriDomains: control base-uri directive - permissions: camera, microphone, geolocation via iframe allow attribute WebGL fix: - Use document.write() instead of srcdoc for inner iframe content (srcdoc creates opaque origin that breaks WebGL canvas updates) - Add worker-src directive with blob: support (critical for WebGL apps like CesiumJS/Three.js that use workers for tile decoding, terrain processing, image processing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: Remove permissions support (defer to PR #158) Keep this PR focused on CSP security fixes only. Permissions (camera, microphone, geolocation) will be handled in #158. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * cleanup McpUiResourceCsp type usage / duplicate defs * fix: guard against CSP injection in domain parameters Validate CSP domain entries to reject characters that could: - Break out of CSP directives (semicolons, newlines) - Inject CSP keywords like 'unsafe-eval' (quotes) - Inject multiple sources in one entry (spaces) This prevents injection attacks where malicious domains could override the security policy. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent df49e88 commit 8b09f42

11 files changed

Lines changed: 572 additions & 155 deletions

File tree

examples/basic-host/sandbox.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
<head>
44
<meta charset="utf-8" />
55
<meta name="color-scheme" content="light dark">
6-
<!-- CSP is set by serve.ts HTTP header - no meta tag needed here
7-
The inner iframe's CSP is dynamically injected based on resource metadata -->
6+
<!-- CSP is set via HTTP headers by serve.ts (based on ?csp= query param).
7+
The inner iframe inherits this CSP since we use document.write(). -->
88
<title>MCP-UI Proxy</title>
99
<style>
1010
html,

examples/basic-host/serve.ts

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22
/**
33
* HTTP servers for the MCP UI example:
44
* - Host server (port 8080): serves host HTML files (React and Vanilla examples)
5-
* - Sandbox server (port 8081): serves sandbox.html with permissive CSP
5+
* - Sandbox server (port 8081): serves sandbox.html with CSP headers
66
*
77
* Running on separate ports ensures proper origin isolation for security.
8+
*
9+
* Security: CSP is set via HTTP headers based on ?csp= query param.
10+
* This ensures content cannot tamper with CSP (unlike meta tags).
811
*/
912

1013
import express from "express";
1114
import cors from "cors";
1215
import { fileURLToPath } from "url";
1316
import { dirname, join } from "path";
17+
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps";
1418

1519
const __filename = fileURLToPath(import.meta.url);
1620
const __dirname = dirname(__filename);
@@ -50,26 +54,74 @@ hostApp.get("/", (_req, res) => {
5054
const sandboxApp = express();
5155
sandboxApp.use(cors());
5256

53-
// Permissive CSP for sandbox content
54-
sandboxApp.use((_req, res, next) => {
55-
const csp = [
56-
"default-src 'self'",
57-
"img-src * data: blob: 'unsafe-inline'",
58-
"style-src * blob: data: 'unsafe-inline'",
59-
"script-src * blob: data: 'unsafe-inline' 'unsafe-eval'",
60-
"connect-src *",
61-
"font-src * blob: data:",
62-
"media-src * blob: data:",
63-
"frame-src * blob: data:",
64-
].join("; ");
65-
res.setHeader("Content-Security-Policy", csp);
57+
// Validate CSP domain entries to prevent injection attacks.
58+
// Rejects entries containing characters that could:
59+
// - `;` or newlines: break out to new CSP directive
60+
// - quotes: inject CSP keywords like 'unsafe-eval'
61+
// - space: inject multiple sources in one entry
62+
function sanitizeCspDomains(domains?: string[]): string[] {
63+
if (!domains) return [];
64+
return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d));
65+
}
66+
67+
function buildCspHeader(csp?: McpUiResourceCsp): string {
68+
const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" ");
69+
const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(" ");
70+
const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(" ") || null;
71+
const baseUriDomains =
72+
sanitizeCspDomains(csp?.baseUriDomains).join(" ") || null;
73+
74+
const directives = [
75+
// Default: allow same-origin + inline styles/scripts (needed for bundled apps)
76+
"default-src 'self' 'unsafe-inline'",
77+
// Scripts: same-origin + inline + eval (some libs need eval) + blob (workers) + specified domains
78+
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
79+
// Styles: same-origin + inline + specified domains
80+
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
81+
// Images: same-origin + data/blob URIs + specified domains
82+
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
83+
// Fonts: same-origin + data/blob URIs + specified domains
84+
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
85+
// Network requests: same-origin + specified API/tile domains
86+
`connect-src 'self' ${connectDomains}`.trim(),
87+
// Workers: same-origin + blob (dynamic workers) + specified domains
88+
// This is critical for WebGL apps (CesiumJS, Three.js) that use workers for:
89+
// - Tile decoding and terrain processing
90+
// - Image processing and texture loading
91+
// - Physics and geometry calculations
92+
`worker-src 'self' blob: ${resourceDomains}`.trim(),
93+
// Nested iframes: use frameDomains if provided, otherwise block all
94+
frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'",
95+
// Plugins: always blocked (defense in depth)
96+
"object-src 'none'",
97+
// Base URI: use baseUriDomains if provided, otherwise block all
98+
baseUriDomains ? `base-uri ${baseUriDomains}` : "base-uri 'none'",
99+
];
100+
101+
return directives.join("; ");
102+
}
103+
104+
// Serve sandbox.html with CSP from query params
105+
sandboxApp.get(["/", "/sandbox.html"], (req, res) => {
106+
// Parse CSP config from query param: ?csp=<url-encoded-json>
107+
let cspConfig: McpUiResourceCsp | undefined;
108+
if (typeof req.query.csp === "string") {
109+
try {
110+
cspConfig = JSON.parse(req.query.csp);
111+
} catch (e) {
112+
console.warn("[Sandbox] Invalid CSP query param:", e);
113+
}
114+
}
115+
116+
// Set CSP via HTTP header - tamper-proof unlike meta tags
117+
const cspHeader = buildCspHeader(cspConfig);
118+
res.setHeader("Content-Security-Policy", cspHeader);
119+
120+
// Prevent caching to ensure fresh CSP on each load
66121
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
67122
res.setHeader("Pragma", "no-cache");
68123
res.setHeader("Expires", "0");
69-
next();
70-
});
71124

72-
sandboxApp.get(["/", "/sandbox.html"], (_req, res) => {
73125
res.sendFile(join(DIRECTORY, "sandbox.html"));
74126
});
75127

examples/basic-host/src/implementation.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
1+
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
22
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
44
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
55

66

7-
const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html");
7+
const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html";
88
const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" };
99

1010

@@ -42,10 +42,7 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
4242

4343
interface UiResourceData {
4444
html: string;
45-
csp?: {
46-
connectDomains?: string[];
47-
resourceDomains?: string[];
48-
};
45+
csp?: McpUiResourceCsp;
4946
}
5047

5148
export interface ToolCallInfo {
@@ -120,7 +117,10 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
120117
}
121118

122119

123-
export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
120+
export function loadSandboxProxy(
121+
iframe: HTMLIFrameElement,
122+
csp?: McpUiResourceCsp,
123+
): Promise<boolean> {
124124
// Prevent reload
125125
if (iframe.src) return Promise.resolve(false);
126126

@@ -140,8 +140,14 @@ export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
140140
window.addEventListener("message", listener);
141141
});
142142

143-
log.info("Loading sandbox proxy...");
144-
iframe.src = SANDBOX_PROXY_URL.href;
143+
// Build sandbox URL with CSP query param for HTTP header-based CSP
144+
const sandboxUrl = new URL(SANDBOX_PROXY_BASE_URL);
145+
if (csp) {
146+
sandboxUrl.searchParams.set("csp", JSON.stringify(csp));
147+
}
148+
149+
log.info("Loading sandbox proxy...", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
150+
iframe.src = sandboxUrl.href;
145151

146152
return readyPromise;
147153
}

examples/basic-host/src/index.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,21 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
263263

264264
useEffect(() => {
265265
const iframe = iframeRef.current!;
266-
loadSandboxProxy(iframe).then((firstTime) => {
267-
// The `firstTime` check guards against React Strict Mode's double
268-
// invocation (mount → unmount → remount simulation in development).
269-
// Outside of Strict Mode, this `useEffect` runs only once per
270-
// `toolCallInfo`.
271-
if (firstTime) {
272-
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
273-
appBridgeRef.current = appBridge;
274-
initializeApp(iframe, appBridge, toolCallInfo);
275-
}
266+
267+
// First get CSP from resource, then load sandbox with CSP in query param
268+
// This ensures CSP is set via HTTP headers (tamper-proof)
269+
toolCallInfo.appResourcePromise.then(({ csp }) => {
270+
loadSandboxProxy(iframe, csp).then((firstTime) => {
271+
// The `firstTime` check guards against React Strict Mode's double
272+
// invocation (mount → unmount → remount simulation in development).
273+
// Outside of Strict Mode, this `useEffect` runs only once per
274+
// `toolCallInfo`.
275+
if (firstTime) {
276+
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
277+
appBridgeRef.current = appBridge;
278+
initializeApp(iframe, appBridge, toolCallInfo);
279+
}
280+
});
276281
});
277282
}, [toolCallInfo]);
278283

examples/basic-host/src/sandbox.ts

Lines changed: 17 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -62,26 +62,9 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
6262
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
6363
// intercepted here (not relayed) because the Sandbox uses it to configure and
6464
// load the inner iframe with the Guest UI HTML content.
65-
// Build CSP meta tag from domains
66-
function buildCspMetaTag(csp?: { connectDomains?: string[]; resourceDomains?: string[] }): string {
67-
const resourceDomains = csp?.resourceDomains?.join(" ") ?? "";
68-
const connectDomains = csp?.connectDomains?.join(" ") ?? "";
69-
70-
// Base CSP directives
71-
const directives = [
72-
"default-src 'self'",
73-
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
74-
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
75-
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
76-
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
77-
`connect-src 'self' ${connectDomains}`.trim(),
78-
"frame-src 'none'",
79-
"object-src 'none'",
80-
"base-uri 'self'",
81-
];
82-
83-
return `<meta http-equiv="Content-Security-Policy" content="${directives.join("; ")}">`;
84-
}
65+
//
66+
// Security: CSP is enforced via HTTP headers on sandbox.html (set by serve.ts
67+
// based on ?csp= query param). This is tamper-proof unlike meta tags.
8568

8669
window.addEventListener("message", async (event) => {
8770
if (event.source === window.parent) {
@@ -98,29 +81,26 @@ window.addEventListener("message", async (event) => {
9881
}
9982

10083
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
101-
const { html, sandbox, csp } = event.data.params;
84+
const { html, sandbox } = event.data.params;
10285
if (typeof sandbox === "string") {
10386
inner.setAttribute("sandbox", sandbox);
10487
}
10588
if (typeof html === "string") {
106-
// Inject CSP meta tag at the start of <head> if CSP is provided
107-
console.log("[Sandbox] Received CSP:", csp);
108-
let modifiedHtml = html;
109-
if (csp) {
110-
const cspMetaTag = buildCspMetaTag(csp);
111-
console.log("[Sandbox] Injecting CSP meta tag:", cspMetaTag);
112-
// Insert after <head> tag if present, otherwise prepend
113-
if (modifiedHtml.includes("<head>")) {
114-
modifiedHtml = modifiedHtml.replace("<head>", `<head>\n${cspMetaTag}`);
115-
} else if (modifiedHtml.includes("<head ")) {
116-
modifiedHtml = modifiedHtml.replace(/<head[^>]*>/, `$&\n${cspMetaTag}`);
117-
} else {
118-
modifiedHtml = cspMetaTag + modifiedHtml;
119-
}
89+
// Use document.write instead of srcdoc for WebGL compatibility.
90+
// srcdoc creates an opaque origin which prevents WebGL canvas updates
91+
// from being displayed properly. document.write preserves the sandbox
92+
// origin, allowing WebGL to work correctly.
93+
// CSP is enforced via HTTP headers on this page (sandbox.html).
94+
const doc = inner.contentDocument || inner.contentWindow?.document;
95+
if (doc) {
96+
doc.open();
97+
doc.write(html);
98+
doc.close();
12099
} else {
121-
console.log("[Sandbox] No CSP provided, using default");
100+
// Fallback to srcdoc if document is not accessible
101+
console.warn("[Sandbox] document.write not available, falling back to srcdoc");
102+
inner.srcdoc = html;
122103
}
123-
inner.srcdoc = modifiedHtml;
124104
}
125105
} else {
126106
if (inner && inner.contentWindow) {

0 commit comments

Comments
 (0)