inspector/clients/web/src/utils/sandbox-csp.test.ts
cliffhall 268bfa1085 fix(web): state the expected shape when dropping a CSP source (#2064)
`approveCspSources` warned "dropping unsafe CSP source", which states a
security verdict the check never makes: `SAFE_CSP_SOURCE` screens each
entry for injection safety only, and deliberately does not bound how
broad a grant is (`resourceDomains: ["*"]` passes).

The four fields are origin lists, so the real cause of a drop is almost
always that the entry isn't an origin — a CSP keyword such as
`'unsafe-eval'` has no field in the contract to be requested through.
Reading it as a safety verdict is what sent #2012 chasing an XSS
regression that wasn't one.

The message now names the field the entry came from (so an app declaring
several lists can tell which one was bad) and states the shape expected,
with no claim about the value's safety.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 01:02:02 -04:00

250 lines
9.7 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
SAFE_CSP_SOURCE,
approveCspSources,
approveSandboxPermissions,
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("names the offending field and states the expected shape, without calling the value unsafe", () => {
// #2064: the filter screens for injection safety only, so the message must
// not read as a verdict on the value. These four fields are origin lists,
// so the real cause is almost always "not an origin" — a CSP keyword has
// no field in the contract to be requested through. Naming the key also
// tells an app declaring several lists which one the bad entry was in.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
approveCspSources({
connectDomains: ["'unsafe-eval'"],
frameDomains: ["<script>"],
});
const messages = warn.mock.calls.map(([message]) => String(message));
expect(messages).toHaveLength(2);
expect(messages[0]).toContain('"connectDomains"');
expect(messages[1]).toContain('"frameDomains"');
for (const message of messages) {
expect(message).toContain("expected an origin");
expect(message).not.toContain("unsafe");
}
expect(warn.mock.calls[0]?.[1]).toBe("'unsafe-eval'");
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("approveSandboxPermissions", () => {
it("keeps the spec's empty-object marker for each known key", () => {
expect(
approveSandboxPermissions({ camera: {}, clipboardWrite: {} }),
).toEqual({ camera: {}, clipboardWrite: {} });
});
it("drops a truthy non-marker the proxy would have honored", () => {
// buildAllowAttribute() tests each key for truthiness, so the string
// "false" — a plausible way for a server to mean "off" — would otherwise
// switch the camera on. `true` is rejected too: it is not a marker the
// spec defines, and guessing in the granting direction is the wrong bet.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
approveSandboxPermissions({
camera: "false",
microphone: true,
geolocation: 1,
clipboardWrite: [],
}),
).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(4);
warn.mockRestore();
});
it("accepts a non-empty object, since `{}` is the spec's extension point", () => {
// The key's presence is the request; a field the host doesn't know can't
// narrow it, and rejecting it would drop grants from servers written
// against a newer spec. The value is normalized to `{}` on the way out.
expect(approveSandboxPermissions({ camera: { enabled: false } })).toEqual({
camera: {},
});
});
it("drops `false` without warning, and ignores unknown keys", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
approveSandboxPermissions({
camera: false,
midi: {},
clipboardWrite: {},
}),
).toEqual({ clipboardWrite: {} });
// `false` is an explicit refusal, not a malformed value.
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it("returns undefined when there is nothing to grant", () => {
expect(approveSandboxPermissions(undefined)).toBeUndefined();
expect(approveSandboxPermissions(null)).toBeUndefined();
expect(approveSandboxPermissions("camera")).toBeUndefined();
expect(approveSandboxPermissions({})).toBeUndefined();
});
});
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 assigns the wrap output to the inner iframe's srcdoc.
// 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);
});
});