Address round-1 @claude review of #1588 (no behavior change to the enforced policy): - approveCspSources / buildSandboxCspPolicy: document that resourceDomains intentionally feeds script-src/style-src per the McpUiResourceCsp contract, and that approve screens injection-safety only (not breadth — a bare `*` is accepted; safe under the opaque-origin sandbox). - CSP_KEYS: replace the `satisfies` (proves listed keys valid) with an exhaustiveCspKeys() helper that ALSO fails to compile if the upstream ext-apps type gains a domain key CSP_KEYS omits, so a requested restriction can never be silently dropped. - SAFE_CSP_SOURCE: accept case-insensitive schemes (URL schemes are case-insensitive); previously over-rejected `HTTPS://…`. Adds tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
170 lines
6.4 KiB
TypeScript
170 lines
6.4 KiB
TypeScript
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);
|
|
});
|
|
});
|