security(vscode): authenticate host→webview messages with a per-boot token - #378
Conversation
…token
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) <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
Traced the boundary independently and the fix holds up. Grepping postMessage across vscode-ext/src returns only extension.ts's two provider.postMessage calls and DormouseViewProvider.postMessage itself — zero direct webview.postMessage left in message-router.ts — so the 37-site conversion really is complete, which was the part most at risk given no live-extension run. Both getWebviewHtml callers (setupPanel, resolveWebviewView) mint before attachRouter, and because VS Code re-renders the stored html string on content recreation, a webview whose document is rebuilt without a re-resolve still carries the token its WeakMap entry holds. The other two window message listeners do validate event.origin, and no code path hands the token down to a frame (.contentWindow and iframe.postMessage have no hits), so the secret stays where the model needs it.
Two minor things inline, neither blocking.
One behavioral note that isn't worth an inline suggestion: DormouseViewProvider assigns this.view = view at the top of resolveWebviewView, then awaits ptyManager.getAvailableShells() before setting view.webview.html. activate() registers its own .then on that same cached promise first, so on a cold start where the panel resolves before shell detection finishes, applyShell → setSelectedShell → postMessage lands inside that window with this.view set but no token minted yet. Functionally that's a no-op — the message was dropped by VS Code before this PR too, and the shell still reaches the webview through the injected global — but it now writes an [error] line into the user-visible Dormouse output channel on an ordinary startup. Moving this.view = view below the view.webview.html = ... assignment closes it and keeps the error log meaning what you want it to mean. Happy to push that as a commit if you'd like.
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) <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
The type-level version of the invariant holds up mechanically, not just by convention: webview.html = and webview.postMessage each appear exactly once across vscode-ext/src, both inside serveWebview, so "the only way to put a document on a webview" and "the only send path" are both grep-checkable now rather than spec bullets. Narrowing attachRouter to the channel also cost it nothing — message-router.ts has no remaining webview. member access besides the two the channel exposes.
The dropped test dispatch really is dead, not just redundant: with the global stubbed undefined the adapter's token is null, and isHostMessage returns at if (!token) return false before touching the envelope, so an explicit __dormouseToken: undefined could not have reached a different branch than the forgedMessage case already covers. And removing log.error is the right end of the fix I raised last round — this.channel is now set only after serveWebview, so a background applyShell → setSelectedShell landing inside the getAvailableShells() await returns false silently instead of writing an [error] line on an ordinary cold start. Same false the newTerminal/debugTheme retry loops already handle.
Two documentation-accuracy nits inline, neither blocking.
Co-authored-by: dormouse-bot <ned.twigg+dormouse-bot@diffplug.com>
Co-authored-by: dormouse-bot <ned.twigg+dormouse-bot@diffplug.com>
Co-authored-by: dormouse-bot <ned.twigg+dormouse-bot@diffplug.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
Only new thing on this push: the test-comment edit applied my earlier suggestion rather than the correction I posted under it, so the sentence now runs together — Framed content can't read the forged message is just this without the stamp. One-line fix inline. The two docs/specs/vscode.md edits landed verbatim and check out against the code (serveWebview returns the WebviewChannel, and setupPanel / resolveWebviewView are indeed the only two sites still holding a raw vscode.Webview).
Co-authored-by: dormouse-bot <ned.twigg+dormouse-bot@diffplug.com>
Fixes the BLOCKER qualitative finding that has been failing the nightly
security-auditworkflow since ~2026-08-06 (tracked in #301). All 30 mechanicalFAIL IFchecks were already passing; this finding is what was red — and becauserelease.yml'spublish-vscodejob listssecurity-auditinneeds:, it also gates the VS Code extension publish.Not closing #301 — the audit closes it automatically on the next passing run.
The vulnerability
VSCodeAdapter'smessagelistener branched purely onevent.data.typeand never established who sent the message.A webview's
windowis a shared inbox. The extension host posts to it, and so can any framed surface —dor iframeand agent-browser surfaces render a real scriptable<iframe>(IframePanel.tsx), andparent.postMessagecrosses origin and sandbox boundaries by design.allow-scriptsalone is sufficient; the sandbox does not help here.So a page inside an iframe surface could run:
The adapter dispatched that as a
dormouse:control-requestCustomEvent,use-dor-control.tsmatchedSURFACE_CONTROL_METHODS.send, and it becamegetPlatform().writePty(target.id, input)— arbitrary shell input into any addressable terminal, bypassing the control-socket token authentication thatdocs/specs/dor-cli.mdestablishes for exactly this operation. The same unguarded listener also accepted forgedpty:data/pty:list/pty:replay, which can spoof terminal state.The fix
A per-boot message token, reusing the injection mechanism that already exists rather than building new machinery:
getWebviewHtmlmints 24 CSPRNG bytes (base64url) per webview document viamintWebviewMessageToken, and injects it asglobalThis.__DORMOUSE_MESSAGE_TOKEN__in the same nonce-gated inline script that already seeds__DORMOUSE_HOST_STATE__/__DORMOUSE_SELECTED_SHELL__. Tokens live in aWeakMapkeyed byvscode.Webview, so they follow webview lifetime with no cleanup and can't drift from the document carrying them.postToWebview, which adds the token. All 37 send sites are covered:attachRouterexposes it as a localpost()(33 sites), andDormouseViewProvider.postMessage— already a chokepoint — routes through it (the remaining 4, including bothextension.tscallers).VSCodeAdaptercaptures the token once at construction and callsisHostMessage(event.data, token)before branching ontype, so the guard covers the whole listener — not justdor:controlRequest. The audit explicitly called outpty:*spoofing too.Framed content cannot read the parent's globals cross-origin, so it cannot produce the token.
Why a token rather than
event.source !== window.parentA source check has to assert something about VS Code's internal webview frame topology, which is undocumented and can change between releases — and which I cannot verify without running the extension. A token depends on nothing but itself, so it can be tested and reasoned about in isolation.
It is deliberately not the CSP nonce, which was already available: that nonce authorizes script execution, this token authenticates a message sender. Different purposes; conflating them makes both harder to reason about, and the nonce appears in markup the page can read.
The second listener
requestResponse(formerlyvscode-adapter.ts:171) installs a per-request listener; it is guarded too. It matches ontype+requestIdand resolves on first match, so a forged reply racing the real one would win — and these replies carry consequential host data (an iframe proxy URL, scrollback, clipboard contents). A test covers exactly that race.Consistency with the existing pattern
The repo already has this shape:
lib/src/lib/iframe-proxy-registry.tsexists souse-wall-keyboard.tscan doif (!isProxyOrigin(e.origin)) return;. The adapter was the one listener that skipped it. The newlib/src/lib/vscode-message-token.tsmirrors that — a small module holding the trust criterion so each listener stays a one-line guard — keyed on an unguessable secret instead of an origin, for the reason above.The legitimate iframe→parent channels are untouched.
IframePanel.tsx:273anduse-wall-keyboard.ts:53have their own listeners and their ownevent.originvalidation; this change is scoped to the adapter's listener. Verified by grep that only threewindowmessagelisteners exist in the codebase and only the adapter's was modified.Failure mode
Fails closed in both directions, and visibly rather than silently:
postToWebviewhas no token to stamp, so it is dropped and reported as undelivered (false) — the same signal the VS Code API gives for a dead webview, which thedormouse:newTerminalretry loop andforwardDorControlRequest's rejection path already handle.Scope: VS Code only — confirmed, not assumed
I checked rather than assuming.
standalone/src/tauri-adapter.tsreceivesdor:controlRequestover Tauri'slisten()IPC (event.payload), andstandalone/src/browser-sidecar-adapter.tsover the dev harness's host WebSocket. Neither registers awindowmessagelistener. A grep foraddEventListener('message'acrosslib/src,standalone/src,website/src,vscode-ext/srcandcanopyreturns exactly five hits: the two WebSocket listeners in the remote stack, and the threewindowlisteners discussed above.Verification
What I did verify:
pnpm build— exit 0.pnpm test— 1444 tests pass (lib 1334, standalone 48, website 62), plusspec-lint: OK (23 specs, 24 files checked).vscode-adapter.test.tstests that dispatch an inbound message failed (rejected) while the 5 outbound-only ones passed. And with the guard temporarily neutered toif (false) return, all 5 new rejection tests fail; with it restored, all pass.New
host message authenticationblock inlib/src/lib/platform/vscode-adapter.test.ts:dormouse:control-requestand posts nothing backpty:data/pty:replay/pty:exit/pty:list/terminal:semanticEventsall droppedWhat I could not verify: I did not exercise the real VS Code extension — no Extension Development Host run, no
pnpm dogfood:vscodeinstall, no manual attempt at the exploit in a live webview. The token injection and the 37 rewritten send sites are covered by the build and by reasoning, not by a runtime test in VS Code. The end-to-end path worth a manual smoke test before merge is: open the Dormouse panel, confirm terminals spawn and stream (provespty:*sends still carry the token), then rundorin a terminal (proves the control-request round trip) and open ador iframesurface.vscode-ext/srcreports pre-existingtsc --noEmiterrors under itstsconfig.json(missing@types/node,rootDircomplaints about../libimports); it is not a build or CI gate — the extension builds with esbuild. I diffed the error sets before and after: the only two additions are exact clones of pre-existing categories (webview-messaging.tsreports the sameCannot find name 'crypto'thatwebview-html.tsalready reports on the identicalimport { randomBytes } from 'crypto', and the new lib import adds one morerootDirline like every other lib import).Specs
docs/specs/vscode.mdowns this boundary and gains a "Webview message authentication" section next to the CSP policy, plus an invariant and the two new files in the architecture tree.docs/specs/transport.mdgains a note that sender authenticity is the adapter's responsibility, not the protocol's — the schema says what a message means, never that it came from the host.Follow-up, deliberately not in this PR
IframePanel.tsx:338appliessandboxonly whenresolution.kind === 'proxied'; therawfallback renders an unsandboxed iframe. That is a real second issue and was noted in the audit finding, but it is a separate change with its own blast radius. Flagging it here so it isn't lost.🤖 Generated with Claude Code