Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 16 additions & 37 deletions vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ import {
removeHookSettings,
renderHookScript,
} from "./claudeHook";
import {
hardenWebviewHtml,
isPathInside,
parseExplorerMessage,
} from "./webviewSecurity";

const DEBOUNCE_MS = 300;
const RELATIONSHIP_DEBOUNCE_MS = 600;
Expand Down Expand Up @@ -567,22 +572,21 @@ async function showExplorer(): Promise<void> {
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<void> {
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);
}
}

Expand Down Expand Up @@ -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<void> {
Expand All @@ -652,32 +655,8 @@ async function loadExplorer(folder: vscode.WorkspaceFolder): Promise<void> {
}
}

// 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 = `<meta http-equiv="Content-Security-Policy" content="${explorerCsp(cspSource)}">`;
// Insert the CSP as the first child of <head> so it governs the whole document.
return html.includes("<head>")
? html.replace("<head>", `<head>${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 (
Expand Down
112 changes: 112 additions & 0 deletions vscode/src/test/suite/webviewSecurity.test.ts
Original file line number Diff line number Diff line change
@@ -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 <head>", () => {
const html = "<html><head><title>Portal</title></head><body></body></html>";
const hardened = hardenWebviewHtml(html, CSP_SOURCE);
const head = hardened.indexOf("<head>");
const meta = hardened.indexOf('<meta http-equiv="Content-Security-Policy"');
const title = hardened.indexOf("<title>");
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 <head>", () => {
const hardened = hardenWebviewHtml("<p>bare fragment</p>", CSP_SOURCE);
assert.ok(
hardened.startsWith('<meta http-equiv="Content-Security-Policy"'),
"meta is prepended to a head-less document",
);
});
});

suite("Explorer bridge message envelope", () => {
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",
);
});
});
71 changes: 71 additions & 0 deletions vscode/src/webviewSecurity.ts
Original file line number Diff line number Diff line change
@@ -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 <head> so it governs the whole document. */
export function hardenWebviewHtml(html: string, cspSource: string): string {
const meta = `<meta http-equiv="Content-Security-Policy" content="${explorerCsp(cspSource)}">`;
return html.includes("<head>")
? html.replace("<head>", `<head>${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);
}
4 changes: 2 additions & 2 deletions vscode/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"]
}
Loading