merge: v2/1558-sandbox-csp (#1588)
This commit is contained in:
commit
e55867cd45
3 changed files with 320 additions and 0 deletions
170
clients/web/src/lib/sandbox-csp.test.ts
Normal file
170
clients/web/src/lib/sandbox-csp.test.ts
Normal 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&b"c'd<e>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"><script>"');
|
||||
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);
|
||||
});
|
||||
});
|
||||
146
clients/web/src/lib/sandbox-csp.ts
Normal file
146
clients/web/src/lib/sandbox-csp.ts
Normal 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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>`;
|
||||
}
|
||||
|
|
@ -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}'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue