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>
This commit is contained in:
Olivier Chafik 2026-01-11 14:32:48 +00:00 • committed by GitHub
commit 8b09f4278c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,10 +1,10 @@
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html");
const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html";
const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" };
@ -42,10 +42,7 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
interface UiResourceData {
html: string;
csp?: {
connectDomains?: string[];
resourceDomains?: string[];
};
csp?: McpUiResourceCsp;
}
export interface ToolCallInfo {
@ -120,7 +117,10 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
}
export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
export function loadSandboxProxy(
iframe: HTMLIFrameElement,
csp?: McpUiResourceCsp,
): Promise<boolean> {
// Prevent reload
if (iframe.src) return Promise.resolve(false);
@ -140,8 +140,14 @@ export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
window.addEventListener("message", listener);
});
log.info("Loading sandbox proxy...");
iframe.src = SANDBOX_PROXY_URL.href;
// Build sandbox URL with CSP query param for HTTP header-based CSP
const sandboxUrl = new URL(SANDBOX_PROXY_BASE_URL);
if (csp) {
sandboxUrl.searchParams.set("csp", JSON.stringify(csp));
}
log.info("Loading sandbox proxy...", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
iframe.src = sandboxUrl.href;
return readyPromise;
}