From 066531aaa0fa0302cc6c149246d124865953da5b Mon Sep 17 00:00:00 2001 From: ochafik Date: Sun, 11 Jan 2026 12:30:03 +0000 Subject: [PATCH 1/4] fix: Move CSP to HTTP headers + add worker-src, frameDomains, baseUriDomains, permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- examples/basic-host/serve.ts | 96 +++++++++++++---- examples/basic-host/src/implementation.ts | 37 +++++-- examples/basic-host/src/index.tsx | 25 +++-- examples/basic-host/src/sandbox.ts | 74 +++++++------- src/generated/schema.json | 99 ++++++++++++++++++ src/generated/schema.test.ts | 22 ++-- src/generated/schema.ts | 119 ++++++++++++++++------ src/spec.types.ts | 26 +++++ src/types.ts | 2 + 9 files changed, 389 insertions(+), 111 deletions(-) diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts index 29dd5b78..c4eb606b 100644 --- a/examples/basic-host/serve.ts +++ b/examples/basic-host/serve.ts @@ -2,9 +2,12 @@ /** * 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 + * - Sandbox server (port 8081): serves sandbox.html with CSP headers * * Running on separate ports ensures proper origin isolation for security. + * + * Security: CSP is set via HTTP headers based on ?csp= query param. + * This ensures content cannot tamper with CSP (unlike meta tags). */ import express from "express"; @@ -50,26 +53,85 @@ hostApp.get("/", (_req, res) => { 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); +/** + * Build CSP header string from config. + * + * The CSP restricts what the sandboxed content can do: + * - script-src: Allow scripts from specified domains + inline/eval (needed for bundled apps) + * - style-src: Allow styles from specified domains + inline + * - img-src: Allow images from specified domains + data/blob URIs + * - font-src: Allow fonts from specified domains + data/blob URIs + * - connect-src: Allow fetch/XHR to specified domains (e.g., tile servers, APIs) + * - worker-src: Allow Web Workers from specified domains + blob URIs + * (Critical for WebGL apps like CesiumJS that use workers for tile decoding) + * - frame-src: Disallow nested iframes (defense in depth) + * - object-src: Disallow plugins (defense in depth) + * - base-uri: Prevent base tag injection attacks + */ +interface CspConfig { + connectDomains?: string[]; + resourceDomains?: string[]; + frameDomains?: string[]; + baseUriDomains?: string[]; +} + +function buildCspHeader(csp?: CspConfig): string { + const resourceDomains = csp?.resourceDomains?.join(" ") ?? ""; + const connectDomains = csp?.connectDomains?.join(" ") ?? ""; + const frameDomains = csp?.frameDomains?.join(" "); + const baseUriDomains = csp?.baseUriDomains?.join(" "); + + const directives = [ + // Default: allow same-origin + inline styles/scripts (needed for bundled apps) + "default-src 'self' 'unsafe-inline'", + // Scripts: same-origin + inline + eval (some libs need eval) + blob (workers) + specified domains + `script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(), + // Styles: same-origin + inline + specified domains + `style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(), + // Images: same-origin + data/blob URIs + specified domains + `img-src 'self' data: blob: ${resourceDomains}`.trim(), + // Fonts: same-origin + data/blob URIs + specified domains + `font-src 'self' data: blob: ${resourceDomains}`.trim(), + // Network requests: same-origin + specified API/tile domains + `connect-src 'self' ${connectDomains}`.trim(), + // Workers: same-origin + blob (dynamic workers) + specified domains + // This is critical for WebGL apps (CesiumJS, Three.js) that use workers for: + // - Tile decoding and terrain processing + // - Image processing and texture loading + // - Physics and geometry calculations + `worker-src 'self' blob: ${resourceDomains}`.trim(), + // Nested iframes: use frameDomains if provided, otherwise block all + frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'", + // Plugins: always blocked (defense in depth) + "object-src 'none'", + // Base URI: use baseUriDomains if provided, otherwise block all + baseUriDomains ? `base-uri ${baseUriDomains}` : "base-uri 'none'", + ]; + + return directives.join("; "); +} + +// Serve sandbox.html with CSP from query params +sandboxApp.get(["/", "/sandbox.html"], (req, res) => { + // Parse CSP config from query param: ?csp= + let cspConfig: CspConfig | undefined; + if (typeof req.query.csp === "string") { + try { + cspConfig = JSON.parse(req.query.csp); + } catch (e) { + console.warn("[Sandbox] Invalid CSP query param:", e); + } + } + + // Set CSP via HTTP header - tamper-proof unlike meta tags + const cspHeader = buildCspHeader(cspConfig); + res.setHeader("Content-Security-Policy", cspHeader); + + // Prevent caching to ensure fresh CSP on each load 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")); }); diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 703ab6a5..113b5e06 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -4,7 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ 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" }; @@ -45,6 +45,13 @@ interface UiResourceData { csp?: { connectDomains?: string[]; resourceDomains?: string[]; + frameDomains?: string[]; + baseUriDomains?: string[]; + }; + permissions?: { + camera?: boolean; + microphone?: boolean; + geolocation?: boolean; }; } @@ -108,19 +115,23 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { +export function loadSandboxProxy( + iframe: HTMLIFrameElement, + csp?: { connectDomains?: string[]; resourceDomains?: string[]; frameDomains?: string[]; baseUriDomains?: string[] }, +): Promise { // Prevent reload if (iframe.src) return Promise.resolve(false); @@ -140,8 +151,14 @@ export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise { 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; } @@ -162,10 +179,10 @@ export async function initializeApp( new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), ); - // Load inner iframe HTML with CSP metadata - const { html, csp } = await appResourcePromise; - log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : ""); - await appBridge.sendSandboxResourceReady({ html, csp }); + // Load inner iframe HTML with CSP and permissions metadata + const { html, csp, permissions } = await appResourcePromise; + log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "", permissions ? `(Permissions: ${JSON.stringify(permissions)})` : ""); + await appBridge.sendSandboxResourceReady({ html, csp, permissions }); // Wait for inner iframe to be ready log.info("Waiting for MCP App to initialize..."); diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index e33fc17c..1e1313d3 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -263,16 +263,21 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI useEffect(() => { const iframe = iframeRef.current!; - loadSandboxProxy(iframe).then((firstTime) => { - // The `firstTime` check guards against React Strict Mode's double - // invocation (mount → unmount → remount simulation in development). - // Outside of Strict Mode, this `useEffect` runs only once per - // `toolCallInfo`. - if (firstTime) { - const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe); - appBridgeRef.current = appBridge; - initializeApp(iframe, appBridge, toolCallInfo); - } + + // First get CSP from resource, then load sandbox with CSP in query param + // This ensures CSP is set via HTTP headers (tamper-proof) + toolCallInfo.appResourcePromise.then(({ csp }) => { + loadSandboxProxy(iframe, csp).then((firstTime) => { + // The `firstTime` check guards against React Strict Mode's double + // invocation (mount → unmount → remount simulation in development). + // Outside of Strict Mode, this `useEffect` runs only once per + // `toolCallInfo`. + if (firstTime) { + const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe); + appBridgeRef.current = appBridge; + initializeApp(iframe, appBridge, toolCallInfo); + } + }); }); }, [toolCallInfo]); diff --git a/examples/basic-host/src/sandbox.ts b/examples/basic-host/src/sandbox.ts index 0a914afe..3c7271cc 100644 --- a/examples/basic-host/src/sandbox.ts +++ b/examples/basic-host/src/sandbox.ts @@ -62,25 +62,24 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] = // 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 Guest UI HTML content. -// Build CSP meta tag from domains -function buildCspMetaTag(csp?: { connectDomains?: string[]; resourceDomains?: string[] }): string { - const resourceDomains = csp?.resourceDomains?.join(" ") ?? ""; - const connectDomains = csp?.connectDomains?.join(" ") ?? ""; - - // Base CSP directives - const directives = [ - "default-src 'self'", - `script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(), - `style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(), - `img-src 'self' data: blob: ${resourceDomains}`.trim(), - `font-src 'self' data: blob: ${resourceDomains}`.trim(), - `connect-src 'self' ${connectDomains}`.trim(), - "frame-src 'none'", - "object-src 'none'", - "base-uri 'self'", - ]; - - return ``; +// +// 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. + +// Build iframe allow attribute from permissions (camera, microphone, geolocation) +function buildAllowAttribute(permissions?: { + camera?: boolean; + microphone?: boolean; + geolocation?: boolean; +}): string { + if (!permissions) return ""; + + const allowList: string[] = []; + if (permissions.camera) allowList.push("camera"); + if (permissions.microphone) allowList.push("microphone"); + if (permissions.geolocation) allowList.push("geolocation"); + + return allowList.join("; "); } window.addEventListener("message", async (event) => { @@ -98,29 +97,32 @@ window.addEventListener("message", async (event) => { } if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) { - const { html, sandbox, csp } = event.data.params; + 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); + } if (typeof html === "string") { - // Inject CSP meta tag at the start of if CSP is provided - console.log("[Sandbox] Received CSP:", csp); - let modifiedHtml = html; - if (csp) { - const cspMetaTag = buildCspMetaTag(csp); - console.log("[Sandbox] Injecting CSP meta tag:", cspMetaTag); - // Insert after tag if present, otherwise prepend - if (modifiedHtml.includes("")) { - modifiedHtml = modifiedHtml.replace("", `\n${cspMetaTag}`); - } else if (modifiedHtml.includes("]*>/, `$&\n${cspMetaTag}`); - } else { - modifiedHtml = cspMetaTag + modifiedHtml; - } + // Use document.write instead of srcdoc for WebGL compatibility. + // srcdoc creates an opaque origin which prevents WebGL canvas updates + // from being displayed properly. document.write preserves the sandbox + // origin, allowing WebGL to work correctly. + // CSP is enforced via HTTP headers on this page (sandbox.html). + const doc = inner.contentDocument || inner.contentWindow?.document; + if (doc) { + doc.open(); + doc.write(html); + doc.close(); } else { - console.log("[Sandbox] No CSP provided, using default"); + // Fallback to srcdoc if document is not accessible + console.warn("[Sandbox] document.write not available, falling back to srcdoc"); + inner.srcdoc = html; } - inner.srcdoc = modifiedHtml; } } else { if (inner && inner.contentWindow) { diff --git a/src/generated/schema.json b/src/generated/schema.json index 7a1fd2ca..bb4e58a1 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -3475,6 +3475,20 @@ "items": { "type": "string" } + }, + "frameDomains": { + "description": "Origins for nested iframes (frame-src directive).", + "type": "array", + "items": { + "type": "string" + } + }, + "baseUriDomains": { + "description": "Allowed base URIs for the document (base-uri directive).", + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false @@ -3500,6 +3514,39 @@ "items": { "type": "string" } + }, + "frameDomains": { + "description": "Origins for nested iframes (frame-src directive).", + "type": "array", + "items": { + "type": "string" + } + }, + "baseUriDomains": { + "description": "Allowed base URIs for the document (base-uri directive).", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "permissions": { + "description": "Sandbox permissions requested by the UI.", + "type": "object", + "properties": { + "camera": { + "description": "Request camera access (Permission Policy `camera` feature).", + "type": "boolean" + }, + "microphone": { + "description": "Request microphone access (Permission Policy `microphone` feature).", + "type": "boolean" + }, + "geolocation": { + "description": "Request geolocation access (Permission Policy `geolocation` feature).", + "type": "boolean" } }, "additionalProperties": false @@ -3515,6 +3562,25 @@ }, "additionalProperties": false }, + "McpUiResourcePermissions": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "camera": { + "description": "Request camera access (Permission Policy `camera` feature).", + "type": "boolean" + }, + "microphone": { + "description": "Request microphone access (Permission Policy `microphone` feature).", + "type": "boolean" + }, + "geolocation": { + "description": "Request geolocation access (Permission Policy `geolocation` feature).", + "type": "boolean" + } + }, + "additionalProperties": false + }, "McpUiResourceTeardownRequest": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -3593,6 +3659,39 @@ "items": { "type": "string" } + }, + "frameDomains": { + "description": "Origins for nested iframes (frame-src directive).", + "type": "array", + "items": { + "type": "string" + } + }, + "baseUriDomains": { + "description": "Allowed base URIs for the document (base-uri directive).", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "permissions": { + "description": "Sandbox permissions from resource metadata.", + "type": "object", + "properties": { + "camera": { + "description": "Request camera access (Permission Policy `camera` feature).", + "type": "boolean" + }, + "microphone": { + "description": "Request microphone access (Permission Policy `microphone` feature).", + "type": "boolean" + }, + "geolocation": { + "description": "Request geolocation access (Permission Policy `geolocation` feature).", + "type": "boolean" } }, "additionalProperties": false diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index e9a57981..becbd115 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -43,8 +43,8 @@ export type McpUiSandboxProxyReadyNotificationSchemaInferredType = z.infer< typeof generated.McpUiSandboxProxyReadyNotificationSchema >; -export type McpUiSandboxResourceReadyNotificationSchemaInferredType = z.infer< - typeof generated.McpUiSandboxResourceReadyNotificationSchema +export type McpUiResourcePermissionsSchemaInferredType = z.infer< + typeof generated.McpUiResourcePermissionsSchema >; export type McpUiSizeChangedNotificationSchemaInferredType = z.infer< @@ -119,6 +119,10 @@ export type McpUiMessageRequestSchemaInferredType = z.infer< typeof generated.McpUiMessageRequestSchema >; +export type McpUiSandboxResourceReadyNotificationSchemaInferredType = z.infer< + typeof generated.McpUiSandboxResourceReadyNotificationSchema +>; + export type McpUiToolResultNotificationSchemaInferredType = z.infer< typeof generated.McpUiToolResultNotificationSchema >; @@ -171,11 +175,11 @@ expectType( expectType( {} as spec.McpUiSandboxProxyReadyNotification, ); -expectType( - {} as McpUiSandboxResourceReadyNotificationSchemaInferredType, +expectType( + {} as McpUiResourcePermissionsSchemaInferredType, ); -expectType( - {} as spec.McpUiSandboxResourceReadyNotification, +expectType( + {} as spec.McpUiResourcePermissions, ); expectType( {} as McpUiSizeChangedNotificationSchemaInferredType, @@ -265,6 +269,12 @@ expectType( expectType( {} as spec.McpUiMessageRequest, ); +expectType( + {} as McpUiSandboxResourceReadyNotificationSchemaInferredType, +); +expectType( + {} as spec.McpUiSandboxResourceReadyNotification, +); expectType( {} as McpUiToolResultNotificationSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 9acf7790..6047a02a 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -186,39 +186,30 @@ export const McpUiSandboxProxyReadyNotificationSchema = z.object({ }); /** - * @description Notification containing HTML resource for the sandbox proxy to load. - * @internal - * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy + * @description Sandbox permissions requested by the UI resource. + * Hosts MAY honor these by setting appropriate iframe `allow` attributes. + * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. */ -export const McpUiSandboxResourceReadyNotificationSchema = z.object({ - method: z.literal("ui/notifications/sandbox-resource-ready"), - params: z.object({ - /** @description HTML content to load into the inner iframe. */ - html: z.string().describe("HTML content to load into the inner iframe."), - /** @description Optional override for the inner iframe's sandbox attribute. */ - sandbox: z - .string() - .optional() - .describe("Optional override for the inner iframe's sandbox attribute."), - /** @description CSP configuration from resource metadata. */ - csp: z - .object({ - /** @description Origins for network requests (fetch/XHR/WebSocket). */ - connectDomains: z - .array(z.string()) - .optional() - .describe("Origins for network requests (fetch/XHR/WebSocket)."), - /** @description Origins for static resources (scripts, images, styles, fonts). */ - resourceDomains: z - .array(z.string()) - .optional() - .describe( - "Origins for static resources (scripts, images, styles, fonts).", - ), - }) - .optional() - .describe("CSP configuration from resource metadata."), - }), +export const McpUiResourcePermissionsSchema = z.object({ + /** @description Request camera access (Permission Policy `camera` feature). */ + camera: z + .boolean() + .optional() + .describe("Request camera access (Permission Policy `camera` feature)."), + /** @description Request microphone access (Permission Policy `microphone` feature). */ + microphone: z + .boolean() + .optional() + .describe( + "Request microphone access (Permission Policy `microphone` feature).", + ), + /** @description Request geolocation access (Permission Policy `geolocation` feature). */ + geolocation: z + .boolean() + .optional() + .describe( + "Request geolocation access (Permission Policy `geolocation` feature).", + ), }); /** @@ -421,6 +412,16 @@ export const McpUiResourceCspSchema = z.object({ .array(z.string()) .optional() .describe("Origins for static resources (scripts, images, styles, fonts)."), + /** @description Origins for nested iframes (frame-src directive). */ + frameDomains: z + .array(z.string()) + .optional() + .describe("Origins for nested iframes (frame-src directive)."), + /** @description Allowed base URIs for the document (base-uri directive). */ + baseUriDomains: z + .array(z.string()) + .optional() + .describe("Allowed base URIs for the document (base-uri directive)."), }); /** @@ -431,6 +432,10 @@ export const McpUiResourceMetaSchema = z.object({ csp: McpUiResourceCspSchema.optional().describe( "Content Security Policy configuration.", ), + /** @description Sandbox permissions requested by the UI. */ + permissions: McpUiResourcePermissionsSchema.optional().describe( + "Sandbox permissions requested by the UI.", + ), /** @description Dedicated origin for widget sandbox. */ domain: z .string() @@ -521,6 +526,56 @@ export const McpUiMessageRequestSchema = z.object({ }), }); +/** + * @description Notification containing HTML resource for the sandbox proxy to load. + * @internal + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy + */ +export const McpUiSandboxResourceReadyNotificationSchema = z.object({ + method: z.literal("ui/notifications/sandbox-resource-ready"), + params: z.object({ + /** @description HTML content to load into the inner iframe. */ + html: z.string().describe("HTML content to load into the inner iframe."), + /** @description Optional override for the inner iframe's sandbox attribute. */ + sandbox: z + .string() + .optional() + .describe("Optional override for the inner iframe's sandbox attribute."), + /** @description CSP configuration from resource metadata. */ + csp: z + .object({ + /** @description Origins for network requests (fetch/XHR/WebSocket). */ + connectDomains: z + .array(z.string()) + .optional() + .describe("Origins for network requests (fetch/XHR/WebSocket)."), + /** @description Origins for static resources (scripts, images, styles, fonts). */ + resourceDomains: z + .array(z.string()) + .optional() + .describe( + "Origins for static resources (scripts, images, styles, fonts).", + ), + /** @description Origins for nested iframes (frame-src directive). */ + frameDomains: z + .array(z.string()) + .optional() + .describe("Origins for nested iframes (frame-src directive)."), + /** @description Allowed base URIs for the document (base-uri directive). */ + baseUriDomains: z + .array(z.string()) + .optional() + .describe("Allowed base URIs for the document (base-uri directive)."), + }) + .optional() + .describe("CSP configuration from resource metadata."), + /** @description Sandbox permissions from resource metadata. */ + permissions: McpUiResourcePermissionsSchema.optional().describe( + "Sandbox permissions from resource metadata.", + ), + }), +}); + /** * @description Notification containing tool execution result (Host -> Guest UI). */ diff --git a/src/spec.types.ts b/src/spec.types.ts index 3a931338..e2c30f63 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -224,7 +224,13 @@ export interface McpUiSandboxResourceReadyNotification { connectDomains?: string[]; /** @description Origins for static resources (scripts, images, styles, fonts). */ resourceDomains?: string[]; + /** @description Origins for nested iframes (frame-src directive). */ + frameDomains?: string[]; + /** @description Allowed base URIs for the document (base-uri directive). */ + baseUriDomains?: string[]; }; + /** @description Sandbox permissions from resource metadata. */ + permissions?: McpUiResourcePermissions; }; } @@ -497,6 +503,24 @@ export interface McpUiResourceCsp { connectDomains?: string[]; /** @description Origins for static resources (scripts, images, styles, fonts). */ resourceDomains?: string[]; + /** @description Origins for nested iframes (frame-src directive). */ + frameDomains?: string[]; + /** @description Allowed base URIs for the document (base-uri directive). */ + baseUriDomains?: string[]; +} + +/** + * @description Sandbox permissions requested by the UI resource. + * Hosts MAY honor these by setting appropriate iframe `allow` attributes. + * Apps SHOULD NOT assume permissions are granted; use JS feature detection as fallback. + */ +export interface McpUiResourcePermissions { + /** @description Request camera access (Permission Policy `camera` feature). */ + camera?: boolean; + /** @description Request microphone access (Permission Policy `microphone` feature). */ + microphone?: boolean; + /** @description Request geolocation access (Permission Policy `geolocation` feature). */ + geolocation?: boolean; } /** @@ -505,6 +529,8 @@ export interface McpUiResourceCsp { export interface McpUiResourceMeta { /** @description Content Security Policy configuration. */ csp?: McpUiResourceCsp; + /** @description Sandbox permissions requested by the UI. */ + permissions?: McpUiResourcePermissions; /** @description Dedicated origin for widget sandbox. */ domain?: string; /** @description Visual boundary preference - true if UI prefers a visible border. */ diff --git a/src/types.ts b/src/types.ts index da4a71fa..006680cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,7 @@ export { type McpUiInitializeResult, type McpUiInitializedNotification, type McpUiResourceCsp, + type McpUiResourcePermissions, type McpUiResourceMeta, type McpUiRequestDisplayModeRequest, type McpUiRequestDisplayModeResult, @@ -110,6 +111,7 @@ export { McpUiInitializeResultSchema, McpUiInitializedNotificationSchema, McpUiResourceCspSchema, + McpUiResourcePermissionsSchema, McpUiResourceMetaSchema, McpUiRequestDisplayModeRequestSchema, McpUiRequestDisplayModeResultSchema, From 73b69e7d32c5ed7bc75171f1f89b15c96288171c Mon Sep 17 00:00:00 2001 From: ochafik Date: Sun, 11 Jan 2026 12:47:17 +0000 Subject: [PATCH 2/4] refactor: Remove permissions support (defer to PR #158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- examples/basic-host/sandbox.html | 4 +- examples/basic-host/src/implementation.ts | 18 +- examples/basic-host/src/sandbox.ts | 24 +- examples/basic-host/src/tamper-detection.ts | 325 ++++++++++++++++++++ src/generated/schema.json | 57 ---- src/generated/schema.test.ts | 22 +- src/generated/schema.ts | 119 +++---- src/spec.types.ts | 18 -- src/types.ts | 2 - 9 files changed, 382 insertions(+), 207 deletions(-) create mode 100644 examples/basic-host/src/tamper-detection.ts diff --git a/examples/basic-host/sandbox.html b/examples/basic-host/sandbox.html index b868e582..e0790fb0 100644 --- a/examples/basic-host/sandbox.html +++ b/examples/basic-host/sandbox.html @@ -3,8 +3,8 @@ - + MCP-UI Proxy