feat(web): add sandbox CSP builder library (closes #1558)
Add a pure, DOM-free CSP-builder library at clients/web/src/lib/sandbox-csp.ts that validates app-supplied `_meta.ui.csp` requests, filters them to safe sources, builds the locked-down Content-Security-Policy string, and wraps untrusted widget HTML in a fixed host shell whose first <head> child is the CSP meta. - SAFE_CSP_SOURCE: strict regex that rejects directive/attribute breakouts (`;`, `"`, `<`, `>`, whitespace). - approveCspSources(): drops unsafe entries (with a warning) and omits empty keys; echoes back only what the host will enforce. - buildSandboxCspPolicy(): `default-src 'none'` catch-all, `form-action 'none'`, source-allowlist filtering, `'unsafe-inline'` for the app's own inline script/style. - escapeHtmlAttr() / wrapSandboxedHtml(): defense-in-depth so untrusted bytes never precede the applied policy. Re-implements the sandbox-csp portion of PR #1510 (Wave 1 of #1579) as a standalone library; wiring into the app bridge is a follow-up issue. Adds full unit tests (100% lines/statements/functions/branches) and lists the file in the web coverage `include` so it is gated (the untested legacy src/lib siblings stay out until they get tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
This commit is contained in:
parent
066676a241
commit
77e57e0d25
3 changed files with 279 additions and 0 deletions
164
clients/web/src/lib/sandbox-csp.test.ts
Normal file
164
clients/web/src/lib/sandbox-csp.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
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",
|
||||
])("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);
|
||||
});
|
||||
});
|
||||
111
clients/web/src/lib/sandbox-csp.ts
Normal file
111
clients/web/src/lib/sandbox-csp.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
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-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])?)$/;
|
||||
|
||||
const CSP_KEYS = [
|
||||
"connectDomains",
|
||||
"resourceDomains",
|
||||
"frameDomains",
|
||||
"baseUriDomains",
|
||||
] as const satisfies readonly (keyof McpUiResourceCsp)[];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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`.
|
||||
*/
|
||||
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