diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 863246cb..f99cca0b 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -225,7 +225,16 @@ Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives ( That allowlist is still a build-time constant, not a runtime value: `vscode-ext/scripts/esbuild.mjs` substitutes `__DORMOUSE_REMOTE_CONNECT_SRC__` into `dist/extension.js`, defaulting to the SaaS origin (`https://*.dormouse.sh wss://*.dormouse.sh`), and `assertConnectSrcBaked` fails the build if the define did not reach the bundle — a lost define would otherwise surface only as a Host silently using the shipped default. `lib/src/host/remote/connect-src.ts` reads it through `bakedConnectSrc()`, as a `declare const` rather than an import, so the value is a literal in the bundle and nothing at runtime can move it. A selfhoster widens it for their own build with `DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode` — the same variable and the same per-build opt-in as the standalone binary (`docs/specs/server.md` → "Where a Host may reach a relay server"). -`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. +`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 every tag that loads a script, and injects initial state via a nonce-gated inline script. + +**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 `\n `, diff --git a/vscode-ext/test/vscode-stub.ts b/vscode-ext/test/vscode-stub.ts index 6aed6a99..06a12409 100644 --- a/vscode-ext/test/vscode-stub.ts +++ b/vscode-ext/test/vscode-stub.ts @@ -3,9 +3,10 @@ * * The extension host modules worth unit-testing barely touch the API — most * import it as `import type`, which erases. What is left is the output channel - * `log.ts` opens, so that is all this provides. Anything else is deliberately - * absent: a test that reaches further should fail loudly rather than pass - * against a fake that quietly does nothing. + * `log.ts` opens and the `Uri.file` that `webview-html.ts` calls, so that is all + * this provides. Anything else is deliberately absent: a test that reaches + * further should fail loudly rather than pass against a fake that quietly does + * nothing. */ export const window = { @@ -14,3 +15,12 @@ export const window = { dispose: () => {}, }), }; + +/** + * `getWebviewHtml` resolves the media directory through `Uri.file` before + * handing it to `asWebviewUri`, which is the only thing that reads it back. A + * plain `fsPath` carrier is the whole contract. + */ +export const Uri = { + file: (fsPath: string) => ({ fsPath }), +}; diff --git a/vscode-ext/test/webview-html.test.ts b/vscode-ext/test/webview-html.test.ts new file mode 100644 index 00000000..1a9bdef6 --- /dev/null +++ b/vscode-ext/test/webview-html.test.ts @@ -0,0 +1,137 @@ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { CSP_NONCE_PLACEHOLDER } from '../src/csp-nonce-placeholder'; +import { getWebviewHtml } from '../src/webview-html'; +import { removeDir, tempStorageDir } from './helpers'; + +/** + * `getWebviewHtml` post-processes whatever Vite built into `media/`, so these + * tests feed it that file's real shape from a temp directory rather than mocking + * the read. Below is verbatim Vite 8 output with `html.cspNonce` set: rolldown + * splits its shared runtime into its own chunk and the entry both imports it and + * carries a `` for it, and Vite marks every tag plus + * the `` that its runtime preload helper reads. + */ +const VITE_INDEX_HTML = ` + + + + Dormouse + + + + + + + +
+ + +`; + +const CSP_SOURCE = 'https://file+.vscode-resource.vscode-cdn.net'; + +const webview = { + cspSource: CSP_SOURCE, + asWebviewUri: (uri: { fsPath: string }) => `${CSP_SOURCE}${uri.fsPath}`, +} as never; + +/** Shared across the suite: every test only reads through `getWebviewHtml`. */ +let mediaPath: string; + +beforeAll(async () => { + mediaPath = await tempStorageDir(); + await writeFile(join(mediaPath, 'index.html'), VITE_INDEX_HTML); +}); + +afterAll(async () => { + await removeDir(mediaPath); +}); + +/** The single nonce the document was served with, read back off its CSP. */ +function nonceOf(html: string): string { + const match = /script-src 'nonce-([A-Za-z0-9_-]+)'/.exec(html); + if (!match) throw new Error('no script-src nonce in the CSP'); + return match[1]; +} + +describe('getWebviewHtml', () => { + it("pairs the nonce with 'strict-dynamic' so split chunks can load", () => { + const { html } = getWebviewHtml(webview, mediaPath); + // A lazy `import()` carries no nonce — a nonce is not inherited through the + // module graph — so without `strict-dynamic` it is blocked, surfacing as a + // render error naming a chunk that is present on disk. + expect(html).toContain(`script-src 'nonce-${nonceOf(html)}' 'strict-dynamic'`); + // `strict-dynamic` widens what a trusted script may load, never what may be + // written into the document. + expect(/script-src[^;]*'unsafe-inline'/.test(html)).toBe(false); + }); + + it('carries the real nonce on every tag Vite marked, and leaves no placeholder', () => { + const { html } = getWebviewHtml(webview, mediaPath); + const nonce = nonceOf(html); + + // Named rather than derived from a predicate: a test that recomputes the + // source's own tag-matching rule agrees with it by construction and would + // pass even if that rule were wrong. These are the tags whose fetches + // `script-src` gates — the entry, the two preloads for the entry's static + // imports, and the meta tag Vite's runtime helper reads before injecting a + // preload for a lazy chunk. + for (const marker of [ + 'index-AAAAAAAA.js', + 'rolldown-runtime-BBBBBBBB.js', + 'alert-ring-watch-CCCCCCCC.js', + 'property="csp-nonce"', + ]) { + const tag = new RegExp(`<[^>]*${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^>]*>`).exec(html)?.[0]; + expect(tag, `no tag found for ${marker}`).toBeDefined(); + expect(tag, `un-nonced: ${tag}`).toContain(`nonce="${nonce}"`); + } + + // The placeholder is a build artifact; letting one reach a browser would + // mean a tag whose nonce matches nothing. + expect(html).not.toContain(CSP_NONCE_PLACEHOLDER); + }); + + it('gives each tag exactly one nonce', () => { + const { html } = getWebviewHtml(webview, mediaPath); + for (const tag of html.match(/<(?:script|link|meta)\b[^>]*>/g) ?? []) { + expect((tag.match(/nonce=/g) ?? []).length, `duplicate nonce: ${tag}`).toBeLessThanOrEqual(1); + } + }); + + it('refuses to serve HTML the build never marked', async () => { + // A dropped `html.cspNonce` would otherwise yield a document whose every + // script is un-nonced against a nonce-gated policy — a blank panel with no + // error outside the webview console. + const unmarked = await tempStorageDir(); + try { + await writeFile( + join(unmarked, 'index.html'), + VITE_INDEX_HTML.replaceAll(CSP_NONCE_PLACEHOLDER, ''), + ); + expect(() => getWebviewHtml(webview, unmarked)).toThrow(/cspNonce/); + } finally { + await removeDir(unmarked); + } + }); + + it('rewrites asset paths onto the webview URI', () => { + const { html } = getWebviewHtml(webview, mediaPath); + expect(html).not.toContain('"./assets/'); + expect(html).toContain(`${CSP_SOURCE}${mediaPath}/assets/index-AAAAAAAA.js`); + expect(html).toContain(`${CSP_SOURCE}${mediaPath}/assets/rolldown-runtime-BBBBBBBB.js`); + }); + + it('mints a fresh nonce and message token per document', () => { + const first = getWebviewHtml(webview, mediaPath); + const second = getWebviewHtml(webview, mediaPath); + expect(nonceOf(first.html)).not.toBe(nonceOf(second.html)); + expect(first.messageToken).not.toBe(second.messageToken); + // The two secrets are deliberately distinct: one authorizes script + // execution, the other authenticates a message sender. + expect(first.messageToken).not.toBe(nonceOf(first.html)); + }); +}); diff --git a/vscode-ext/vite.config.ts b/vscode-ext/vite.config.ts index 82d0e196..391d9a0b 100644 --- a/vscode-ext/vite.config.ts +++ b/vscode-ext/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import path from "path"; +import { CSP_NONCE_PLACEHOLDER } from "./src/csp-nonce-placeholder"; /** * Builds the lib frontend for embedding in the VSCode extension webview. @@ -9,6 +10,16 @@ import path from "path"; */ export default defineConfig({ plugins: [react(), tailwindcss()], + html: { + // Vite stamps this placeholder onto every tag it emits that loads a script + // or style, and onto a `` that its own runtime + // preload helper reads. `webview-html.ts` swaps it for a fresh per-render + // nonce when it serves the document — the placeholder must never reach a + // browser. Letting Vite mark the tags keeps nonce coverage tied to the + // bundler's own output shape instead of to regexes that have to guess it + // (docs/specs/vscode.md → "CSP policy"). + cspNonce: CSP_NONCE_PLACEHOLDER, + }, resolve: { dedupe: ["react", "react-dom"], }, diff --git a/vscode-ext/vitest.config.mts b/vscode-ext/vitest.config.mts index 0bcb7ab7..3ceb6d52 100644 --- a/vscode-ext/vitest.config.mts +++ b/vscode-ext/vitest.config.mts @@ -3,7 +3,8 @@ import { defineConfig } from 'vitest/config'; /** * Unit tests for the extension host. The `vscode` module only exists inside a * running VS Code, so it is aliased to a stub; everything under test either - * imports it as a type (erased) or goes through `log.ts`. + * imports it as a type (erased) or touches the small surface the stub carries + * (the `log.ts` output channel, and `Uri.file` for `webview-html.ts`). * * Modules that genuinely need the real editor — commands, webview hosting — are * not covered here and would need `@vscode/test-electron`.