diff --git a/clients/web/src/lib/sandbox-csp.test.ts b/clients/web/src/lib/sandbox-csp.test.ts index 8beee978..3cd471c2 100644 --- a/clients/web/src/lib/sandbox-csp.test.ts +++ b/clients/web/src/lib/sandbox-csp.test.ts @@ -19,6 +19,12 @@ describe("SAFE_CSP_SOURCE", () => { "data:", "blob:", "example.com", + // Schemes are case-insensitive per the URL spec, so a capitalized or + // mixed-case scheme is still a valid source (over-rejecting it would + // silently drop a restriction a server asked for). + "HTTPS://api.example.com", + "WSS://realtime.service.com", + "Https://*.cloudflare.com", ])("accepts %s", (s) => { expect(SAFE_CSP_SOURCE.test(s)).toBe(true); }); diff --git a/clients/web/src/lib/sandbox-csp.ts b/clients/web/src/lib/sandbox-csp.ts index 7e607a88..b3d44004 100644 --- a/clients/web/src/lib/sandbox-csp.ts +++ b/clients/web/src/lib/sandbox-csp.ts @@ -10,14 +10,31 @@ import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge * dropped by {@link approveCspSources}. */ export const SAFE_CSP_SOURCE = - /^(?:\*|[a-z][a-z0-9+.-]*:(?:\/\/(?:\*\.)?[A-Za-z0-9._~%!$&'()*+,=@:-]+(?::\d+)?(?:\/[A-Za-z0-9._~%!$&'()*+,=@:/-]*)?)?|(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)$/; + /^(?:\*|[a-zA-Z][a-zA-Z0-9+.-]*:(?:\/\/(?:\*\.)?[A-Za-z0-9._~%!$&'()*+,=@:-]+(?::\d+)?(?:\/[A-Za-z0-9._~%!$&'()*+,=@:/-]*)?)?|(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)$/; -const CSP_KEYS = [ +/** + * Identity helper that pins `CSP_KEYS` to an exhaustive, valid key list at + * compile time. `satisfies readonly (keyof McpUiResourceCsp)[]` alone only + * proves every *listed* key is valid; it does NOT prove the list is *complete*. + * The intersected conditional adds the missing half: when `CSP_KEYS` covers + * every key, `keyof McpUiResourceCsp extends T[number]` holds and the parameter + * type is just `T`; if the upstream ext-apps type ever gains a new domain key, + * the conditional collapses the parameter type to `never` and the call fails to + * compile — forcing the key to be added here rather than being silently dropped + * (a vanished restriction) by {@link approveCspSources}. + */ +function exhaustiveCspKeys( + keys: T & (keyof McpUiResourceCsp extends T[number] ? unknown : never), +): T { + return keys; +} + +const CSP_KEYS = exhaustiveCspKeys([ "connectDomains", "resourceDomains", "frameDomains", "baseUriDomains", -] as const satisfies readonly (keyof McpUiResourceCsp)[]; +]); /** * Filter an app-supplied {@link McpUiResourceCsp} down to the entries the host @@ -26,6 +43,16 @@ const CSP_KEYS = [ * return value is what the host echoes back to the view via * `hostCapabilities.sandbox.csp` so the app sees what was granted, not what it * asked for. + * + * NOTE: this screens each source for *injection safety* only — it does NOT + * bound the *breadth* of a grant. A bare `*` (and a scheme-wildcard host) is a + * syntactically safe source, so `resourceDomains: ["*"]` is "approved" and maps + * to `script-src 'unsafe-inline' *` (scripts from anywhere), just as + * `connectDomains: ["*"]` maps to `connect-src *`. That breadth is acceptable + * here because the enforced document runs in an opaque-origin sandbox with no + * ambient credentials and nothing to exfiltrate beyond what the app already + * received; "approved" therefore means "cannot break out of the meta + * attribute," not "restrictive." */ export function approveCspSources( csp: McpUiResourceCsp | undefined, @@ -59,6 +86,14 @@ function joinSources(list: string[] | undefined, fallback: string): string { * `style-src` carry `'unsafe-inline'` because the app's own inline code ships * with the inline-delivered HTML and has no origin to allowlist; external loads * stay restricted to `resourceDomains`. + * + * `resourceDomains` intentionally feeds `script-src` (and `style-src`) in + * addition to `img-src`/`font-src`/`media-src`: the `McpUiResourceCsp` contract + * defines it as a single "static resources" allowlist that "Maps to CSP + * `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives," so + * an app that lists a CDN there is granted script execution from that origin by + * design. There is no narrower per-directive key in the contract; if the spec + * ever splits scripts out, update the mapping here accordingly. */ export function buildSandboxCspPolicy(approved: McpUiResourceCsp): string { const resourceSrc = joinSources(approved.resourceDomains, "'none'");