From 77e57e0d2539db2462d215c2337e133effbd77fb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 1 Jul 2026 18:23:13 -0400 Subject: [PATCH] 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 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) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- clients/web/src/lib/sandbox-csp.test.ts | 164 ++++++++++++++++++++++++ clients/web/src/lib/sandbox-csp.ts | 111 ++++++++++++++++ clients/web/vite.config.ts | 4 + 3 files changed, 279 insertions(+) create mode 100644 clients/web/src/lib/sandbox-csp.test.ts create mode 100644 clients/web/src/lib/sandbox-csp.ts diff --git a/clients/web/src/lib/sandbox-csp.test.ts b/clients/web/src/lib/sandbox-csp.test.ts new file mode 100644 index 00000000..8beee978 --- /dev/null +++ b/clients/web/src/lib/sandbox-csp.test.ts @@ -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>", + "app`; + const wrapped = wrapSandboxedHtml(evil, "default-src 'none'"); + // The first in the output is OURS; the untrusted token only + // appears after . + const ourHead = wrapped.indexOf(""); + const body = wrapped.indexOf(""); + const evilHead = wrapped.indexOf("", ourHead + 1); + expect(ourHead).toBeLessThan(body); + expect(evilHead).toBeGreaterThan(body); + }); + + it("does not introduce a host-authored