diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 3fecb08..cfbe9d2 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -53,6 +53,11 @@ import { removeHookSettings, renderHookScript, } from "./claudeHook"; +import { + hardenWebviewHtml, + isPathInside, + parseExplorerMessage, +} from "./webviewSecurity"; const DEBOUNCE_MS = 300; const RELATIONSHIP_DEBOUNCE_MS = 600; @@ -567,22 +572,21 @@ async function showExplorer(): Promise { await loadExplorer(folder); } -// A message from the Explorer webview. Only known types act, and `open-artifact` -// resolves the id against the cached export — so the path opened is the engine's, -// never one supplied by the webview, and it is confined to the workspace. +// A message from the Explorer webview. Only known envelopes act (the parse is +// pinned by unit tests in webviewSecurity), and `open-artifact` resolves the id +// against the cached export — so the path opened is the engine's, never one +// supplied by the webview, and it is confined to the workspace. async function handleExplorerMessage( folder: vscode.WorkspaceFolder, message: unknown, ): Promise { - if (typeof message !== "object" || message === null) return; - const type = (message as { type?: unknown }).type; - if (type === "ready") { + const parsed = parseExplorerMessage(message); + if (parsed.kind === "ready") { await revealArtifact(folder, vscode.window.activeTextEditor?.document); return; } - if (type === "open-artifact") { - const id = (message as { id?: unknown }).id; - if (typeof id === "string") await openArtifactById(folder, id); + if (parsed.kind === "open-artifact") { + await openArtifactById(folder, parsed.id); } } @@ -626,8 +630,7 @@ async function revealArtifact( } function isInsideFolder(folder: vscode.WorkspaceFolder, uri: vscode.Uri): boolean { - const rel = path.relative(folder.uri.fsPath, uri.fsPath); - return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); + return isPathInside(folder.uri.fsPath, uri.fsPath); } async function loadExplorer(folder: vscode.WorkspaceFolder): Promise { @@ -652,32 +655,8 @@ async function loadExplorer(folder: vscode.WorkspaceFolder): Promise { } } -// A strict Content-Security-Policy for the Explorer webview. The exported Portal -// is self-contained — inline scripts and styles, data: fonts and images, and no -// network — so `default-src 'none'` blocks any exfiltration (there is no -// connect-src). Inline script/style and data: assets keep the offline viewer -// working; the vendored shell uses no eval/WebAssembly, so 'unsafe-eval' is -// deliberately not granted. `cspSource` is included so VS Code's own injected -// webview resources (e.g. the `acquireVsCodeApi` bridge the sync relies on) are -// permitted. The bridge itself stays origin-checked in handleExplorerMessage -// (known message types only; opened paths confined to the workspace). -function explorerCsp(cspSource: string): string { - return [ - "default-src 'none'", - `img-src ${cspSource} data:`, - `font-src ${cspSource} data:`, - `style-src ${cspSource} 'unsafe-inline'`, - `script-src ${cspSource} 'unsafe-inline'`, - ].join("; "); -} - -function hardenWebviewHtml(html: string, cspSource: string): string { - const meta = ``; - // Insert the CSP as the first child of so it governs the whole document. - return html.includes("") - ? html.replace("", `${meta}`) - : `${meta}${html}`; -} +// The CSP and bridge-envelope hardening live in webviewSecurity.ts, where the +// battery unit-tests them (v0.21.10 initiative 2). function explorerMessage(text: string): string { return ( diff --git a/vscode/src/test/suite/webviewSecurity.test.ts b/vscode/src/test/suite/webviewSecurity.test.ts new file mode 100644 index 0000000..130fc1d --- /dev/null +++ b/vscode/src/test/suite/webviewSecurity.test.ts @@ -0,0 +1,112 @@ +import * as assert from "assert"; +import * as path from "path"; + +import { + explorerCsp, + hardenWebviewHtml, + isPathInside, + parseExplorerMessage, +} from "../../webviewSecurity"; + +// Regression coverage for the publish-blocking webview hardening (v0.21.10 +// initiative 2): the strict CSP on the Explorer webview, the validated bridge +// envelope, and the workspace path confinement. Pure functions — no live +// editor needed (same pattern as claudeHook.test.ts). + +const CSP_SOURCE = "vscode-resource://test-source"; + +suite("Explorer webview CSP", () => { + test("denies everything by default and grants no network", () => { + const csp = explorerCsp(CSP_SOURCE); + assert.ok(csp.startsWith("default-src 'none'"), "default-src 'none' leads"); + assert.ok(!csp.includes("connect-src"), "no connect-src — no exfiltration channel"); + assert.ok(!csp.includes("unsafe-eval"), "eval is not granted"); + }); + + test("permits the webview's own resource origin", () => { + const csp = explorerCsp(CSP_SOURCE); + for (const directive of ["img-src", "font-src", "style-src", "script-src"]) { + assert.ok( + csp.includes(`${directive} ${CSP_SOURCE}`), + `${directive} grants the cspSource`, + ); + } + }); + + test("injects the CSP meta as the first child of ", () => { + const html = "Portal"; + const hardened = hardenWebviewHtml(html, CSP_SOURCE); + const head = hardened.indexOf(""); + const meta = hardened.indexOf('"); + assert.ok(meta > head, "meta is inside head"); + assert.ok(meta < title, "meta precedes all other head children"); + assert.ok(hardened.includes("default-src 'none'"), "the strict policy is present"); + }); + + test("still applies a CSP when the document has no ", () => { + const hardened = hardenWebviewHtml("

bare fragment

", CSP_SOURCE); + assert.ok( + hardened.startsWith(' { + test("accepts exactly the known shapes", () => { + assert.deepStrictEqual(parseExplorerMessage({ type: "ready" }), { kind: "ready" }); + assert.deepStrictEqual(parseExplorerMessage({ type: "open-artifact", id: "RAC-1" }), { + kind: "open-artifact", + id: "RAC-1", + }); + }); + + test("ignores forged and malformed messages", () => { + const forged: unknown[] = [ + null, + undefined, + "open-artifact", + 42, + { type: "evil-command" }, + { type: "open-artifact" }, // no id + { type: "open-artifact", id: 42 }, // non-string id + { type: "open-artifact", id: "" }, // empty id + { type: ["open-artifact"], id: "RAC-1" }, // non-string type + ]; + for (const message of forged) { + assert.deepStrictEqual( + parseExplorerMessage(message), + { kind: "ignored" }, + `rejected: ${JSON.stringify(message)}`, + ); + } + }); + + test("an id is an opaque token for export resolution, never a path", () => { + // Even a path-shaped id parses as an id only; the host resolves it against + // the engine's export and confines the resulting path (isPathInside). + const parsed = parseExplorerMessage({ type: "open-artifact", id: "../../etc/passwd" }); + assert.deepStrictEqual(parsed, { kind: "open-artifact", id: "../../etc/passwd" }); + }); +}); + +suite("Workspace path confinement", () => { + const root = path.resolve(path.sep, "workspace", "corpus"); + + test("accepts paths strictly inside the root", () => { + assert.ok(isPathInside(root, path.join(root, "rac", "decisions", "adr-001.md"))); + }); + + test("rejects the root itself, escapes, and outside paths", () => { + assert.ok(!isPathInside(root, root), "the root itself is not inside"); + assert.ok( + !isPathInside(root, path.join(root, "..", "other", "file.md")), + "a ..-escape is rejected", + ); + assert.ok( + !isPathInside(root, path.resolve(path.sep, "etc", "passwd")), + "an absolute outside path is rejected", + ); + }); +}); diff --git a/vscode/src/webviewSecurity.ts b/vscode/src/webviewSecurity.ts new file mode 100644 index 0000000..6433ab8 --- /dev/null +++ b/vscode/src/webviewSecurity.ts @@ -0,0 +1,71 @@ +/** + * Webview security seams for the RAC Explorer (v0.21.10 initiative 2). + * + * Pure, vscode-free helpers so the publish-blocking hardening contract is + * unit-testable outside the extension host (same pattern as claudeHook.ts): + * the strict CSP applied to the exported Portal HTML, the validated message + * envelope for the webview → host bridge, and the workspace path confinement + * that `open-artifact` resolution relies on. + */ + +import * as path from "node:path"; + +/** + * A strict Content-Security-Policy for the Explorer webview. The exported + * Portal is self-contained — inline scripts and styles, data: fonts and + * images, and no network — so `default-src 'none'` blocks any exfiltration + * (there is no connect-src). Inline script/style and data: assets keep the + * offline viewer working; the vendored shell uses no eval/WebAssembly, so + * 'unsafe-eval' is deliberately not granted. `cspSource` is included so + * VS Code's own injected webview resources (e.g. the `acquireVsCodeApi` + * bridge the sync relies on) are permitted. + */ +export function explorerCsp(cspSource: string): string { + return [ + "default-src 'none'", + `img-src ${cspSource} data:`, + `font-src ${cspSource} data:`, + `style-src ${cspSource} 'unsafe-inline'`, + `script-src ${cspSource} 'unsafe-inline'`, + ].join("; "); +} + +/** Insert the CSP as the first child of so it governs the whole document. */ +export function hardenWebviewHtml(html: string, cspSource: string): string { + const meta = ``; + return html.includes("") + ? html.replace("", `${meta}`) + : `${meta}${html}`; +} + +/** + * The validated message envelope for the Explorer bridge. Anything that is + * not exactly a known shape is `ignored` — the host acts only on `ready` and + * on `open-artifact` with a non-empty string id (which is then resolved + * against the engine's export, never used as a path). + */ +export type ExplorerMessage = + | { kind: "ready" } + | { kind: "open-artifact"; id: string } + | { kind: "ignored" }; + +export function parseExplorerMessage(message: unknown): ExplorerMessage { + if (typeof message !== "object" || message === null) return { kind: "ignored" }; + const type = (message as { type?: unknown }).type; + if (type === "ready") return { kind: "ready" }; + if (type === "open-artifact") { + const id = (message as { id?: unknown }).id; + if (typeof id === "string" && id.length > 0) return { kind: "open-artifact", id }; + } + return { kind: "ignored" }; +} + +/** + * True when `target` is strictly inside `root` (both file-system paths): + * not root itself, no `..` escape, no absolute jump. The confinement check + * behind opening artifact paths from the webview. + */ +export function isPathInside(root: string, target: string): boolean { + const rel = path.relative(root, target); + return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); +} diff --git a/vscode/tsconfig.test.json b/vscode/tsconfig.test.json index fd9b1f3..acc1559 100644 --- a/vscode/tsconfig.test.json +++ b/vscode/tsconfig.test.json @@ -1,5 +1,5 @@ { - "comment": "Compiles the @vscode/test-electron integration tests (src/test) to CommonJS in out/, which the VS Code test runner loads via require. claudeHook.ts is included so its pure helpers (no vscode dependency) can be unit-tested directly. Separate from the bundle build (esbuild) and the noEmit type-check (tsconfig.json).", + "comment": "Compiles the @vscode/test-electron integration tests (src/test) to CommonJS in out/, which the VS Code test runner loads via require. claudeHook.ts and webviewSecurity.ts are included so their pure helpers (no vscode dependency) can be unit-tested directly. Separate from the bundle build (esbuild) and the noEmit type-check (tsconfig.json).", "compilerOptions": { "target": "ES2022", "module": "CommonJS", @@ -13,5 +13,5 @@ "sourceMap": true, "types": ["node", "mocha", "vscode"] }, - "include": ["src/test", "src/claudeHook.ts"] + "include": ["src/test", "src/claudeHook.ts", "src/webviewSecurity.ts"] }