merge: v2/1558-sandbox-csp (#1588)

This commit is contained in:
cliffhall 2026-07-01 22:04:12 -04:00
commit e55867cd45
3 changed files with 320 additions and 0 deletions

View file

@ -0,0 +1,170 @@
import { describe, expect, it, vi } from "vitest";
import {
SAFE_CSP_SOURCE,
approveCspSources,
buildSandboxCspPolicy,
escapeHtmlAttr,
wrapSandboxedHtml,
} from "./sandbox-csp";
describe("SAFE_CSP_SOURCE", () => {
it.each([
"*",
"https://api.example.com",
"https://api.example.com:8443",
"https://api.example.com/path/seg",
"wss://realtime.service.com",
"*.example.com",
"https://*.cloudflare.com",
"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);
});
it.each([
"https://a.com; script-src *",
'https://a.com" onload=',
"https://a.com>",
"<script>",
" https://a.com",
"https://a.com ",
"",
"javascript:alert(1)//;x",
])("rejects %s", (s) => {
expect(SAFE_CSP_SOURCE.test(s)).toBe(false);
});
});
describe("approveCspSources", () => {
it("returns an empty object for undefined / empty input", () => {
expect(approveCspSources(undefined)).toEqual({});
expect(approveCspSources({})).toEqual({});
});
it("keeps safe entries per key and drops unsafe ones with a warning", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const approved = approveCspSources({
connectDomains: ["https://api.ok.com", "https://x.com; script-src *"],
resourceDomains: ["data:", '">'],
frameDomains: [],
baseUriDomains: ["https://b.ok.com"],
});
expect(approved).toEqual({
connectDomains: ["https://api.ok.com"],
resourceDomains: ["data:"],
baseUriDomains: ["https://b.ok.com"],
});
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
it("omits a key when every entry is rejected", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(approveCspSources({ connectDomains: ['"; x'] })).toEqual({});
warn.mockRestore();
});
it("ignores non-array values without throwing", () => {
expect(
approveCspSources({
connectDomains: "https://x.com" as unknown as string[],
}),
).toEqual({});
});
});
describe("buildSandboxCspPolicy", () => {
it("emits locked-down defaults for an empty approved csp", () => {
const policy = buildSandboxCspPolicy({});
expect(policy).toContain("default-src 'none'");
expect(policy).toContain("connect-src 'none'");
expect(policy).toContain("script-src 'unsafe-inline'");
expect(policy).toContain("style-src 'unsafe-inline'");
expect(policy).toContain("img-src 'none'");
expect(policy).toContain("font-src 'none'");
expect(policy).toContain("media-src 'none'");
expect(policy).toContain("frame-src 'none'");
expect(policy).toContain("base-uri 'self'");
expect(policy).toContain("form-action 'none'");
expect(policy).toContain("object-src 'none'");
expect(policy).toContain("worker-src 'none'");
});
it("maps approved domains to the correct directives", () => {
const policy = buildSandboxCspPolicy({
connectDomains: ["https://api.a.com", "wss://rt.a.com"],
resourceDomains: ["https://cdn.a.com"],
frameDomains: ["https://embed.a.com"],
baseUriDomains: ["https://a.com"],
});
expect(policy).toContain("connect-src https://api.a.com wss://rt.a.com");
expect(policy).toContain("script-src 'unsafe-inline' https://cdn.a.com");
expect(policy).toContain("style-src 'unsafe-inline' https://cdn.a.com");
expect(policy).toContain("img-src https://cdn.a.com");
expect(policy).toContain("font-src https://cdn.a.com");
expect(policy).toContain("media-src https://cdn.a.com");
expect(policy).toContain("frame-src https://embed.a.com");
expect(policy).toContain("base-uri https://a.com");
// catch-all directives stay locked down regardless of approved domains
expect(policy).toContain("default-src 'none'");
expect(policy).toContain("form-action 'none'");
expect(policy).toContain("object-src 'none'");
expect(policy).toContain("worker-src 'none'");
});
});
describe("escapeHtmlAttr", () => {
it("encodes &, quotes, and angle brackets", () => {
expect(escapeHtmlAttr(`a&b"c'd<e>f`)).toBe(
"a&amp;b&quot;c&#39;d&lt;e&gt;f",
);
});
});
describe("wrapSandboxedHtml", () => {
it("places the CSP meta as the literal first <head> child before any untrusted bytes", () => {
const wrapped = wrapSandboxedHtml("<p>hi</p>", "default-src 'none'");
const metaIdx = wrapped.indexOf("Content-Security-Policy");
const bodyIdx = wrapped.indexOf("<body>");
expect(metaIdx).toBeGreaterThan(-1);
expect(metaIdx).toBeLessThan(bodyIdx);
expect(wrapped.startsWith("<!DOCTYPE html><html><head><meta")).toBe(true);
});
it("attribute-encodes the policy value", () => {
const wrapped = wrapSandboxedHtml("", `x"><script>`);
expect(wrapped).toContain('content="x&quot;&gt;&lt;script&gt;"');
expect(wrapped).not.toContain('"><script>');
});
it("places untrusted full-document HTML inside <body> so its own <head> token cannot precede the policy", () => {
const evil = `<!-- <head> --><!DOCTYPE html><html><head><script src="x"></script></head><body>app</body></html>`;
const wrapped = wrapSandboxedHtml(evil, "default-src 'none'");
// The first <head> in the output is OURS; the untrusted <head> token only
// appears after <body>.
const ourHead = wrapped.indexOf("<head>");
const body = wrapped.indexOf("<body>");
const evilHead = wrapped.indexOf("<head>", ourHead + 1);
expect(ourHead).toBeLessThan(body);
expect(evilHead).toBeGreaterThan(body);
});
it("does not introduce a host-authored <iframe>, so the widget's window.parent remains the sandbox proxy", () => {
// The proxy writes the wrap output into the inner iframe via document.write.
// If the wrap inserted its own <iframe>, the widget's window.parent would
// point at the wrap's intermediate frame instead of the proxy, breaking the
// postMessage relay (and the proxy's event.source === inner.contentWindow
// check). The only <iframe>s in the output must be ones the untrusted app
// brought itself.
const wrapped = wrapSandboxedHtml("<p>no frames here</p>", "default-src *");
expect(wrapped).not.toMatch(/<iframe/i);
});
});

View file

@ -0,0 +1,146 @@
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
/**
* Allowed shapes for a CSP source-expression supplied by an app's
* `_meta.ui.csp`. Each entry is server-supplied and untrusted: it MUST NOT
* inject extra directives (`;`) or break out of the meta attribute
* (`"`, `<`, `>`). Only common source forms are accepted —
* `scheme://host[:port][/path]`, scheme-only (`data:`, `blob:`), `*`, and
* wildcard hosts (`*.example.com`, `https://*.example.com`); anything else is
* dropped by {@link approveCspSources}.
*/
export const SAFE_CSP_SOURCE =
/^(?:\*|[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])?)$/;
/**
* 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<const T extends readonly (keyof McpUiResourceCsp)[]>(
keys: T & (keyof McpUiResourceCsp extends T[number] ? unknown : never),
): T {
return keys;
}
const CSP_KEYS = exhaustiveCspKeys([
"connectDomains",
"resourceDomains",
"frameDomains",
"baseUriDomains",
]);
/**
* Filter an app-supplied {@link McpUiResourceCsp} down to the entries the host
* will actually enforce. Unsafe values are dropped (and warned), and the
* resulting object contains only keys with at least one accepted source. The
* 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,
): McpUiResourceCsp {
const approved: McpUiResourceCsp = {};
if (!csp) return approved;
for (const key of CSP_KEYS) {
const requested = csp[key];
if (!Array.isArray(requested)) continue;
const accepted: string[] = [];
for (const entry of requested) {
if (typeof entry === "string" && SAFE_CSP_SOURCE.test(entry)) {
accepted.push(entry);
} else {
console.warn("[mcp-app sandbox] dropping unsafe CSP source:", entry);
}
}
if (accepted.length > 0) approved[key] = accepted;
}
return approved;
}
function joinSources(list: string[] | undefined, fallback: string): string {
return list && list.length > 0 ? list.join(" ") : fallback;
}
/**
* Translate an approved {@link McpUiResourceCsp} into the Content-Security-Policy
* string enforced on the inner sandboxed document. `default-src 'none'` is the
* catch-all so any fetch type not explicitly mapped is denied. `script-src` /
* `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'");
const inlineResource =
resourceSrc === "'none'"
? "'unsafe-inline'"
: `'unsafe-inline' ${resourceSrc}`;
return [
"default-src 'none'",
`connect-src ${joinSources(approved.connectDomains, "'none'")}`,
`script-src ${inlineResource}`,
`style-src ${inlineResource}`,
`img-src ${resourceSrc}`,
`font-src ${resourceSrc}`,
`media-src ${resourceSrc}`,
`frame-src ${joinSources(approved.frameDomains, "'none'")}`,
`base-uri ${joinSources(approved.baseUriDomains, "'self'")}`,
"form-action 'none'",
"object-src 'none'",
"worker-src 'none'",
].join("; ");
}
/** HTML-attribute-encode a string (defense-in-depth for the CSP meta value). */
export function escapeHtmlAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* Wrap an app's untrusted HTML in a host-authored document whose first
* `<head>` child is the CSP `<meta>`. The wrapper bytes are fixed — the
* untrusted content lands inside `<body>` and never precedes the policy, so a
* `<head>`/`<!-- -->` token in the app's HTML cannot push the meta inert or
* load resources before the policy applies. If the app's HTML is itself a full
* document, the second `<!doctype>`/`<html>`/`<head>` are parsed inside
* `<body>` (the HTML parser ignores duplicate document-structure tags) while
* its scripts and styles still run — governed by the already-applied policy.
*/
export function wrapSandboxedHtml(
untrustedHtml: string,
policy: string,
): string {
const meta = `<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttr(policy)}">`;
return `<!DOCTYPE html><html><head>${meta}<meta charset="utf-8"></head><body>${untrustedHtml}</body></html>`;
}

View file

@ -101,6 +101,10 @@ export default defineConfig(({ command }) => {
include: [
'src/components/**/*.{ts,tsx}',
'src/utils/**/*.{ts,tsx}',
// Pure, fully-tested sandbox CSP builder library. Listed by file (not a
// `src/lib/**` glob) so the untested legacy siblings in src/lib are not
// pulled under the gate before they have tests of their own.
'src/lib/sandbox-csp.{ts,tsx}',
'clients/web/server/**/*.{ts,tsx}',
path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/react/**/*.{ts,tsx}'),