fix(vscode): let the webview CSP survive a code-split bundle - #470
Conversation
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
Deploying mouseterm with
|
| 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 |
dormouse-bot
left a comment
There was a problem hiding this comment.
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.md→ Testing the extension host still says "Six files, all undervscode-ext/test/" and enumerates them.webview-html.test.tsis 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.tsopens — most modules worth testing importvscodeasimport type, which erases." This PR addsUri.fileprecisely becausewebview-html.tscalls 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 throughlog.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.
| **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. |
There was a problem hiding this comment.
| - **`'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
|
Both folded in as 34de27e — you were right on both counts, and I'd missed that the stub sentence was wrong in both halves.
On the 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. |
|
Confirmed against 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. |
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-srcwas a bare nonce, and a nonce does not reach the chunks the entry loads.What broke
10468f71, lockfile-only renovate bump)<link rel="modulepreload">for them. A preload is fetched as a script, soscript-srcgates it — and only the element's own nonce can satisfy it. Result: blank panel.b824e485"Make VS Code a first-class remote Host"main.tsxpassenableRemoteHost={isVscode}, so the webview began mounting the lazyRemotePairingModalHostat boot — the firstimport()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
enableRemoteHost, so its lazy chunk was never fetched. Verified at the tag.script-src 'self', a host-source rather than a nonce, so every same-origin chunk passes regardless of how it loads.mainsince 2026-08-18 was broken, sopnpm dogfood:vscodewas 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/simplifypass) replaces those regexes with Vite's own mechanism:html.cspNonceinvscode-ext/vite.config.tsmakes Vite stamp a placeholder onto every script/style tag it emits, using a real HTML parser.getWebviewHtmlsubstitutes 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 lazyimport(). It widens what a trusted script may load, never what may be written into the document; nothing grantsscript-src 'unsafe-inline'.<meta property="csp-nonce">before injecting a preload for a lazy chunk. Nothing emitted that tag, so it found none.html.cspNonceemits it.getWebviewHtmlthrows if the placeholder is absent rather than serving un-nonced scripts against a nonce-gated policy — same reasoning asassertConnectSrcBaked, 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-srcnever 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-lintclean.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.mdnow records that debugging fact alongside the mechanism.🤖 Generated with Claude Code
https://claude.ai/code/session_011imCAAwd1M6nSFhJyNNLB4