Skip to content

fix(vscode): let the webview CSP survive a code-split bundle - #470

Merged
nedtwigg merged 3 commits into
mainfrom
fix/webview-csp-split-chunks
Aug 28, 2026
Merged

fix(vscode): let the webview CSP survive a code-split bundle#470
nedtwigg merged 3 commits into
mainfrom
fix/webview-csp-split-chunks

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

The Dormouse panel in VS Code rendered as an empty tab. Two regressions, both landed after v1.1.0 and three days apart, stacked on one cause: script-src was a bare nonce, and a nonce does not reach the chunks the entry loads.

What broke

When What it did
Vite 8.1.5 → 8.2.1 (10468f71, lockfile-only renovate bump) 2026-08-15 Started splitting shared chunks and emitting <link rel="modulepreload"> for them. A preload is fetched as a script, so script-src gates it — and only the element's own nonce can satisfy it. Result: blank panel.
b824e485 "Make VS Code a first-class remote Host" 2026-08-18 Made main.tsx pass enableRemoteHost={isVscode}, so the webview began mounting the lazy RemotePairingModalHost at boot — the first import() it had ever issued. A nonce is not inherited through the module graph. Result: render error naming a chunk that was sitting on disk.

The first masked the second: the panel died before it could render far enough to reach the lazy import.

Verified by building at 10468f71^ in a throwaway worktree — vite 8.1.5 emits one entry script and zero modulepreload links; 8.2.1 emits seven chunks and preloads two.

Blast radius

  • No release is affected. v1.1.0 (2026-07-16) predates the vite bump and never set enableRemoteHost, so its lazy chunk was never fetched. Verified at the tag.
  • Standalone (Tauri) is immune — its CSP is script-src 'self', a host-source rather than a nonce, so every same-origin chunk passes regardless of how it loads.
  • Only main since 2026-08-18 was broken, so pnpm dogfood:vscode was the only way to hit it.

The fix

Two commits. The first adds 'strict-dynamic' and nonces the preload links by regex. The second (a /simplify pass) replaces those regexes with Vite's own mechanism:

  • Vite marks its output. html.cspNonce in vscode-ext/vite.config.ts makes Vite stamp a placeholder onto every script/style tag it emits, using a real HTML parser. getWebviewHtml substitutes one placeholder and matches no tags at all — nonce coverage now tracks the bundler's emitted shape instead of a regex's guess at it, which is what failed here twice.
  • 'strict-dynamic' covers the fetches no tag represents — the entry's static imports and every lazy import(). It widens what a trusted script may load, never what may be written into the document; nothing grants script-src 'unsafe-inline'.
  • Closes a gap the regexes could not reach. Vite's runtime preload helper is already in the shipped bundle and looks for <meta property="csp-nonce"> before injecting a preload for a lazy chunk. Nothing emitted that tag, so it found none. html.cspNonce emits it.
  • Fails loudly. getWebviewHtml throws if the placeholder is absent rather than serving un-nonced scripts against a nonce-gated policy — same reasoning as assertConnectSrcBaked, since that failure otherwise just looks like a blank panel.

Tests

New vscode-ext/test/webview-html.test.ts (6 cases) against a fixture of real Vite output: 'strict-dynamic' present, script-src never gains 'unsafe-inline', each named script-loading tag carries the real nonce, no placeholder survives, no tag carries two, and an unmarked document is refused. The tag assertions name the tags rather than re-deriving the source's matching rule — a computed predicate agrees with the source by construction and cannot catch that rule being wrong.

Confirmed working in the real host after pnpm dogfood:vscode. 112 extension tests pass; spec-lint clean.

Review note

This is the second time a Vite output-shape change silently broke this file, and the only symptom either time was a CSP violation in the webview console — nothing reaches an extension-host log, and the extension activates normally. docs/specs/vscode.md now records that debugging fact alongside the mechanism.

🤖 Generated with Claude Code

https://claude.ai/code/session_011imCAAwd1M6nSFhJyNNLB4

nedtwigg and others added 2 commits August 28, 2026 14:28
The Dormouse panel rendered as an empty tab. Two regressions, both after
v1.1.0 and three days apart, stacked on the same cause: `script-src` was a
bare nonce, and a nonce does not reach chunks the entry loads.

Vite 8.1.5 -> 8.2.1 (10468f7, a lockfile-only renovate bump) started
splitting shared chunks and emitting `<link rel="modulepreload">` for them.
A preload is fetched as a script, so `script-src` gates it, and only the
element's own nonce can satisfy it. The blocked preload left an errored
entry in the module map that the entry chunk's static import resolved to,
so nothing mounted.

Behind that, b824e48 made `main.tsx` pass `enableRemoteHost={isVscode}`,
so the webview began mounting the lazy `RemotePairingModalHost` at boot —
the first `import()` it had ever issued. A nonce is not inherited through
the module graph, so that fetch was blocked too, surfacing as a render
error naming a chunk that was sitting on disk.

Nonce the preload links, and pair the nonce with `strict-dynamic` for the
imports. Inline scripts stay blocked either way.

Neither regression reached a release: v1.1.0 predates the vite bump and
never set `enableRemoteHost`. Standalone is immune — its CSP is
`script-src 'self'`, a host-source rather than a nonce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011imCAAwd1M6nSFhJyNNLB4
/simplify findings. The nonce was applied by hand-rolled regexes over Vite's
built HTML — the same guessing at bundler output shape that caused the bug.
Vite 8.2.1 ships `html.cspNonce`: it walks its own output with a real HTML
parser and marks every script/style tag, so `webview-html.ts` now substitutes
one placeholder and matches no tags at all. Net deletion.

That also closes a gap the regexes could not reach. Vite's runtime preload
helper is already in the shipped bundle and looks for a
`<meta property="csp-nonce">` before injecting a preload for a lazy chunk;
nothing emitted that meta tag, so it found none. `html.cspNonce` emits it.

`getWebviewHtml` now throws when the placeholder is absent rather than serving
un-nonced scripts against a nonce-gated policy — the same reasoning as
`assertConnectSrcBaked`, since that failure looks like a blank panel.

Also from the review: the test re-derived the source's own tag-matching rule,
so it agreed with it by construction and could not catch that rule being
wrong — it now names the tags it expects. Reuse `tempStorageDir`/`removeDir`
from test/helpers.ts, drop a dead `toString` from the vscode stub, collapse
three tag loops into one, and trim comments the spec now owns.

Dropped the "stylesheet link carries no nonce" case: Vite nonces stylesheet
links by design, and `style-src` has no nonce to satisfy either way, so it
pinned an artifact of the old workaround rather than an invariant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011imCAAwd1M6nSFhJyNNLB4
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 34de27e
Status: ✅  Deploy successful!
Preview URL: https://1f59abfb.mouseterm.pages.dev
Branch Preview URL: https://fix-webview-csp-split-chunks.mouseterm.pages.dev

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 CSP change against Vite 8.2.1's own source (injectNonceAttributeTagHook stamps script, style, and link[rel=stylesheet|modulepreload|preload]; injectCspNonceMetaTagHook emits the <meta property="csp-nonce">; the runtime helper reads it via ?.nonce || getAttribute('nonce')), and the mechanism holds — the placeholder covers every parser-inserted tag script-src gates, 'strict-dynamic' covers the script-initiated fetches no tag represents, and the inline state script is injected after the swap so it can't be double-nonced. style-src gaining nonce attributes on its stylesheet links is harmless: with no nonce-source in that directive, 'unsafe-inline' stays live and the links match webview.cspSource.

Two things to fold in, both outside the diff, both in the "spec must match the code" bucket:

  • docs/specs/vscode.mdTesting the extension host still says "Six files, all under vscode-ext/test/" and enumerates them. webview-html.test.ts is a seventh and isn't listed — and it's the one file in that directory whose subject is not real I/O, which is the section's stated organizing principle, so it needs a line rather than just a bumped count.
  • The same section describes the stub as "a stub providing just the output channel log.ts opens — most modules worth testing import vscode as import type, which erases." This PR adds Uri.file precisely because webview-html.ts calls it at runtime, so that sentence is now wrong in both halves. vscode-ext/vitest.config.mts's own header comment carries the same claim ("everything under test either imports it as a type (erased) or goes through log.ts") and has the same problem; the stub's doc comment was updated but these two weren't.

Happy to push both as a commit if you'd rather not hand-edit.

Comment thread docs/specs/vscode.md
**A nonce alone does not survive code splitting.** Vite splits the webview bundle, and `script-src` gates each way a chunk loads separately. Two mechanisms cover them, and the split is not negotiable — a nonce is **not** inherited through the module graph, and `'strict-dynamic'` does not vouch for a parser-started fetch:

- **Vite stamps the nonce** onto every tag it emits, via `html.cspNonce` in `vscode-ext/vite.config.ts` (the placeholder is `CSP_NONCE_PLACEHOLDER`, shared by the config and `webview-html.ts` from `vscode-ext/src/csp-nonce-placeholder.ts`). That covers the entry `<script>`, the `<link rel="modulepreload">` tags for its static imports, and the `<meta property="csp-nonce">` that Vite's own runtime preload helper reads before injecting a preload for a lazy chunk. Vite walks its output with a real HTML parser, so coverage follows the bundler's emitted shape rather than a regex's guess at it. `getWebviewHtml` then swaps the placeholder for that document's real nonce, and **throws if the placeholder is absent** — an unmarked build would otherwise serve un-nonced scripts against a nonce-gated policy, which looks exactly like a blank panel.
- **`'strict-dynamic'` covers the fetches no tag represents:** the entry's own static imports and every lazy `import()`. It widens what an already-trusted script may *load*, never what may be *written into* the document, and nothing here grants `script-src 'unsafe-inline'`. It also makes host-source expressions inert, so adding `webview.cspSource` to `script-src` would be dead weight.

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.

Suggested change
- **`'strict-dynamic'` covers the fetches no tag represents:** the entry's own static imports and every lazy `import()`. It widens what an already-trusted script may *load*, never what may be *written into* the document, and nothing here grants `script-src 'unsafe-inline'`. It also makes host-source expressions inert, so adding `webview.cspSource` to `script-src` would be dead weight.
- **`'strict-dynamic'` covers the fetches no tag represents:** the entry's own static imports and every lazy `import()`. It widens what an already-trusted script may *load*, never what may be *written into* the document, and nothing here grants `script-src 'unsafe-inline'`. It also makes host-source expressions inert, so adding `webview.cspSource` to `script-src` would be dead weight. What it widens is unbounded by origin: a script that already runs here may load one from anywhere, and since `default-src 'none'` leaves every other directive pinned to `webview.cspSource` or loopback, this is now the only request to an arbitrary external origin the policy permits. The trade is deliberate — `'strict-dynamic'` still blocks a parser-inserted `<script>` carrying no nonce, which a `webview.cspSource` allowlist in `script-src` would have let run.

PR review: the section still said "Six files" and described the stub as
providing only the output channel, both of which this branch made untrue.
`webview-html.test.ts` is a seventh, and it is the one file there whose
subject is not real I/O — the section's stated organizing principle — so it
gets a line saying why it lives there anyway.

`vitest.config.mts` carried the same stale claim about the stub in its own
header comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011imCAAwd1M6nSFhJyNNLB4
@nedtwigg

Copy link
Copy Markdown
Member Author

Both folded in as 34de27e — you were right on both counts, and I'd missed that the stub sentence was wrong in both halves.

  • docs/specs/vscode.mdTesting the extension host: now seven files, with a line for webview-html.test.ts that says why a pure string transform earns a place in a directory organized around real I/O (the contract it pins is invisible outside the webview console, so there is nowhere else it can go red early). Also narrowed the "not covered" line to webview hosting, since the document itself is now covered.
  • The stub description: rewritten to name Uri.file alongside the output channel, and vscode-ext/vitest.config.mts had the same claim in its own header comment — fixed there too.

On the style-src point: agreed, and worth stating explicitly since it looks alarming in the diff — the stylesheet links picking up nonce attributes is inert because style-src has no nonce-source, so 'unsafe-inline' stays live and the links match webview.cspSource regardless. I dropped an earlier test that asserted stylesheet links carry no nonce; it was pinning an artifact of the regex approach rather than an invariant, and it would have blocked this fix.

Follow-up coming as a stacked PR: a shallow browser smoketest that loads the built webview bundle under the real CSP. The unit tests here pin the transform against a fixture of Vite output, which means a future Vite shape change goes stale silently — the same blind spot in a new place. Only actually running the thing closes it.

@dormouse-bot

Copy link
Copy Markdown
Collaborator

Confirmed against 34de27e9 — both are closed. The spec's stub sentence now names Uri.file as well as the output channel, the vitest.config.mts header carries the same correction, and the webview-html.test.ts entry states the reason it sits in a real-I/O directory rather than just bumping the count. Narrowing "not covered" to webview hosting is the right cut: the document is the part that now has a test, and the part that needs a real editor is the part that doesn't.

No remaining concerns from my side. The stacked smoketest is the gap worth closing — a fixture of Vite output pins the transform against the shape Vite emitted when the fixture was written, which is the same staleness that let 8.2.1 through, just relocated from a regex to a test file.

@nedtwigg
nedtwigg merged commit 48d9d3a into main Aug 28, 2026
7 checks passed
@nedtwigg
nedtwigg deleted the fix/webview-csp-split-chunks branch August 28, 2026 22:19
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