diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts index bc3fbff14..cdc517483 100644 --- a/examples/basic-host/serve.ts +++ b/examples/basic-host/serve.ts @@ -15,6 +15,7 @@ import cors from "cors"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps"; +import { buildCspHeader } from "./src/csp"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -54,55 +55,6 @@ hostApp.get("/", (_req, res) => { const sandboxApp = express(); sandboxApp.use(cors()); -// Validate CSP domain entries to prevent injection attacks. -// Rejects entries containing characters that could: -// - `;` or newlines: break out to new CSP directive -// - quotes: inject CSP keywords like 'unsafe-eval' -// - space: inject multiple sources in one entry -function sanitizeCspDomains(domains?: string[]): string[] { - if (!domains) return []; - return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d)); -} - -function buildCspHeader(csp?: McpUiResourceCsp): string { - const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" "); - const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(" "); - const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(" ") || null; - const baseUriDomains = - sanitizeCspDomains(csp?.baseUriDomains).join(" ") || null; - - 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(), - // Media (audio/video): same-origin + data/blob URIs + specified domains - `media-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= diff --git a/examples/basic-host/src/csp.ts b/examples/basic-host/src/csp.ts new file mode 100644 index 000000000..85acec77a --- /dev/null +++ b/examples/basic-host/src/csp.ts @@ -0,0 +1,59 @@ +import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps"; + +// Validate CSP domain entries to prevent injection attacks. +// Rejects entries containing characters that could: +// - `;` or newlines: break out to new CSP directive +// - quotes: inject CSP keywords like 'unsafe-eval' +// - space: inject multiple sources in one entry +function sanitizeCspDomains(domains?: string[]): string[] { + if (!domains) return []; + return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d)); +} + +export function buildCspHeader(csp?: McpUiResourceCsp): string { + const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" "); + const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(" "); + const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(" ") || null; + const baseUriDomains = + sanitizeCspDomains(csp?.baseUriDomains).join(" ") || null; + + const scriptSources = [ + "'self'", + "'unsafe-inline'", + "'unsafe-eval'", + ...(csp?.wasmUnsafeEval === true ? ["'wasm-unsafe-eval'"] : []), + "blob:", + "data:", + ].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 ${scriptSources} ${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(), + // Media (audio/video): same-origin + data/blob URIs + specified domains + `media-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("; "); +} diff --git a/examples/basic-host/test/csp.test.ts b/examples/basic-host/test/csp.test.ts new file mode 100644 index 000000000..28f2e2ce5 --- /dev/null +++ b/examples/basic-host/test/csp.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; + +import { buildCspHeader } from "../src/csp"; + +function getScriptSrc(cspHeader: string): string { + const scriptSrc = cspHeader + .split("; ") + .find((directive) => directive.startsWith("script-src ")); + + if (!scriptSrc) { + throw new Error(`Missing script-src directive in CSP: ${cspHeader}`); + } + + return scriptSrc; +} + +describe("buildCspHeader", () => { + it("adds wasm-unsafe-eval to script-src when requested", () => { + const scriptSrc = getScriptSrc( + buildCspHeader({ + resourceDomains: ["https://cdn.example.com"], + wasmUnsafeEval: true, + }), + ); + + expect(scriptSrc).toContain("'wasm-unsafe-eval'"); + expect(scriptSrc).toContain("https://cdn.example.com"); + }); + + it("omits wasm-unsafe-eval from script-src when not requested", () => { + const scriptSrc = getScriptSrc(buildCspHeader()); + + expect(scriptSrc).not.toContain("'wasm-unsafe-eval'"); + }); +}); diff --git a/specification/2026-01-26/apps.mdx b/specification/2026-01-26/apps.mdx index 43fef209d..5aceeef68 100644 --- a/specification/2026-01-26/apps.mdx +++ b/specification/2026-01-26/apps.mdx @@ -133,6 +133,13 @@ interface McpUiResourceCsp { * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] */ resourceDomains?: string[], + /** + * Whether the UI requires WebAssembly compilation + * + * - Empty or false = WebAssembly compilation remains blocked by default + * - Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'` + */ + wasmUnsafeEval?: boolean, /** * Origins for nested iframes * @@ -1729,10 +1736,11 @@ Hosts MUST enforce Content Security Policies based on resource metadata. ```typescript const csp = resource._meta?.ui?.csp; // `resource` is extracted from the `contents` of the `resources/read` result const permissions = resource._meta?.ui?.permissions; +const wasmUnsafeEval = csp?.wasmUnsafeEval ? "'wasm-unsafe-eval'" : ""; const cspValue = ` default-src 'none'; - script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; + script-src 'self' 'unsafe-inline' ${wasmUnsafeEval} ${csp?.resourceDomains?.join(' ') || ''}; style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; connect-src 'self' ${csp?.connectDomains?.join(' ') || ''}; img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 1a14d3f00..8b201beab 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -131,6 +131,13 @@ interface McpUiResourceCsp { * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] */ resourceDomains?: string[], + /** + * Whether the UI requires WebAssembly compilation + * + * - Empty or false = WebAssembly compilation remains blocked by default + * - Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'` + */ + wasmUnsafeEval?: boolean, /** * Origins for nested iframes * @@ -2514,10 +2521,11 @@ Hosts MUST enforce Content Security Policies based on resource metadata. const uiMeta = resource._meta?.ui ?? listingResource._meta?.ui; const csp = uiMeta?.csp; const permissions = uiMeta?.permissions; +const wasmUnsafeEval = csp?.wasmUnsafeEval ? "'wasm-unsafe-eval'" : ""; const cspValue = ` default-src 'none'; - script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; + script-src 'self' 'unsafe-inline' ${wasmUnsafeEval} ${csp?.resourceDomains?.join(' ') || ''}; style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; connect-src 'self' ${csp?.connectDomains?.join(' ') || ''}; img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; diff --git a/src/generated/schema.json b/src/generated/schema.json index 80b4ac60d..7c3f86ee5 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -396,6 +396,10 @@ "type": "string" } }, + "wasmUnsafeEval": { + "description": "Whether the UI requires WebAssembly compilation.\n\n- Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'`\n- Empty or false → WebAssembly compilation remains blocked by default", + "type": "boolean" + }, "frameDomains": { "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted → no nested iframes allowed (`frame-src 'none'`)", "type": "array", @@ -2749,6 +2753,10 @@ "type": "string" } }, + "wasmUnsafeEval": { + "description": "Whether the UI requires WebAssembly compilation.\n\n- Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'`\n- Empty or false → WebAssembly compilation remains blocked by default", + "type": "boolean" + }, "frameDomains": { "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted → no nested iframes allowed (`frame-src 'none'`)", "type": "array", @@ -4141,6 +4149,10 @@ "type": "string" } }, + "wasmUnsafeEval": { + "description": "Whether the UI requires WebAssembly compilation.\n\n- Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'`\n- Empty or false → WebAssembly compilation remains blocked by default", + "type": "boolean" + }, "frameDomains": { "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted → no nested iframes allowed (`frame-src 'none'`)", "type": "array", @@ -4180,6 +4192,10 @@ "type": "string" } }, + "wasmUnsafeEval": { + "description": "Whether the UI requires WebAssembly compilation.\n\n- Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'`\n- Empty or false → WebAssembly compilation remains blocked by default", + "type": "boolean" + }, "frameDomains": { "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted → no nested iframes allowed (`frame-src 'none'`)", "type": "array", @@ -4349,6 +4365,10 @@ "type": "string" } }, + "wasmUnsafeEval": { + "description": "Whether the UI requires WebAssembly compilation.\n\n- Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'`\n- Empty or false → WebAssembly compilation remains blocked by default", + "type": "boolean" + }, "frameDomains": { "description": "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted → no nested iframes allowed (`frame-src 'none'`)", "type": "array", diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 43687374e..fc0202d6c 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -248,6 +248,18 @@ export const McpUiResourceCspSchema = z.object({ .describe( "Origins for static resources (images, scripts, stylesheets, fonts, media).\n\n- Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives\n- Wildcard subdomains supported: `https://*.example.com`\n- Empty or omitted \u2192 no network resources (secure default)", ), + /** + * @description Whether the UI requires WebAssembly compilation. + * + * - Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'` + * - Empty or false → WebAssembly compilation remains blocked by default + */ + wasmUnsafeEval: z + .boolean() + .optional() + .describe( + "Whether the UI requires WebAssembly compilation.\n\n- Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'`\n- Empty or false \u2192 WebAssembly compilation remains blocked by default", + ), /** * @description Origins for nested iframes. * diff --git a/src/spec.types.ts b/src/spec.types.ts index 7a8b33761..4ef11654c 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -628,6 +628,13 @@ export interface McpUiResourceCsp { * ``` */ resourceDomains?: string[]; + /** + * @description Whether the UI requires WebAssembly compilation. + * + * - Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'` + * - Empty or false → WebAssembly compilation remains blocked by default + */ + wasmUnsafeEval?: boolean; /** * @description Origins for nested iframes. *