From 63e3b2eee4d58da56786a6333f517b9b492528c7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 28 May 2026 17:01:53 +0900 Subject: [PATCH] fix(browser): disable client `cdp` API when `allowWrite/allowExec: false` (#10444) Co-authored-by: Codex --- docs/api/browser/commands.md | 2 ++ docs/api/browser/context.md | 2 ++ docs/config/browser/api.md | 4 +-- packages/browser-playwright/src/playwright.ts | 18 +++------- .../browser/src/node/commands/coverage.ts | 16 +++++++++ packages/browser/src/node/commands/index.ts | 3 ++ packages/browser/src/node/rpc.ts | 24 +++++++++++++ packages/coverage-v8/src/browser.ts | 18 +++++----- packages/vitest/src/node/types/browser.ts | 12 +++++++ test/browser/specs/errors.test.ts | 35 +++++++++++++++++- .../browser-api-permissions.browser.test.ts | 36 +++++++++++++++++++ 11 files changed, 144 insertions(+), 26 deletions(-) create mode 100644 packages/browser/src/node/commands/coverage.ts create mode 100644 test/coverage-test/test/browser-api-permissions.browser.test.ts diff --git a/docs/api/browser/commands.md b/docs/api/browser/commands.md index 73f782c5b5f1..af72781a11d3 100644 --- a/docs/api/browser/commands.md +++ b/docs/api/browser/commands.md @@ -59,6 +59,8 @@ expect(input).toHaveValue('a') ::: warning CDP session works only with `playwright` provider and only when using `chromium` browser. You can read more about it in playwright's [`CDPSession`](https://playwright.dev/docs/api/class-cdpsession) documentation. + +CDP is a privileged debugging API. It is available only when browser API write and exec operations are enabled through [`browser.api.allowWrite`](/config/browser/api#api-allowwrite), [`browser.api.allowExec`](/config/browser/api#api-allowexec), [`api.allowWrite`](/config/api#api-allowwrite), and [`api.allowExec`](/config/api#api-allowexec). ::: ## Custom Commands diff --git a/docs/api/browser/context.md b/docs/api/browser/context.md index f4e78b2c0167..97658f8a372f 100644 --- a/docs/api/browser/context.md +++ b/docs/api/browser/context.md @@ -211,6 +211,8 @@ The `cdp` export returns the current Chrome DevTools Protocol session. It is mos ::: warning CDP session works only with `playwright` provider and only when using `chromium` browser. You can read more about it in playwright's [`CDPSession`](https://playwright.dev/docs/api/class-cdpsession) documentation. + +CDP is a privileged debugging API. It is available only when browser API write and exec operations are enabled through [`browser.api.allowWrite`](/config/browser/api#api-allowwrite), [`browser.api.allowExec`](/config/browser/api#api-allowexec), [`api.allowWrite`](/config/api#api-allowwrite), and [`api.allowExec`](/config/api#api-allowexec). ::: ```ts diff --git a/docs/config/browser/api.md b/docs/config/browser/api.md index 1e2a9bfb5dc3..86e85c61f943 100644 --- a/docs/config/browser/api.md +++ b/docs/config/browser/api.md @@ -16,7 +16,7 @@ Configure options for Vite server that serves code in the browser. Does not affe - **Type:** `boolean` - **Default:** `true` if not exposed to the network, `false` otherwise -Vitest saves [annotation attachments](/guide/test-annotations), [artifacts](/api/advanced/artifacts) and [snapshots](/guide/snapshot) by receiving a WebSocket connection from the browser. This allows anyone who can connect to the API write any arbitrary code on your machine within the root of your project (configured by [`fs.allow`](https://vite.dev/config/server-options#server-fs-allow)). +Vitest saves [annotation attachments](/guide/test-annotations), [artifacts](/api/advanced/artifacts) and [snapshots](/guide/snapshot) by receiving a WebSocket connection from the browser. This allows anyone who can connect to the API write any arbitrary code on your machine within the root of your project (configured by [`fs.allow`](https://vite.dev/config/server-options#server-fs-allow)). This option also gates privileged browser APIs that can write files indirectly, such as raw Chrome DevTools Protocol access through [`cdp()`](/api/browser/context#cdp). If browser server is not exposed to the internet (the host is `localhost`), this should not be a problem, so the default value in that case is `true`. If you override the host, Vitest will set `allowWrite` to `false` by default to prevent potentially harmful writes. @@ -25,4 +25,4 @@ If browser server is not exposed to the internet (the host is `localhost`), this - **Type:** `boolean` - **Default:** `true` if not exposed to the network, `false` otherwise -Allows running any test file via the UI. This only applies to the interactive elements (and the server code behind them) in the [UI](/guide/ui) that can run the code. If UI is disabled, this has no effect. See [`api.allowExec`](/config/api#api-allowexec) for more information. +Allows running any test file via the UI. This applies to the interactive elements (and the server code behind them) in the [UI](/guide/ui) that can run the code. This option also gates privileged browser APIs that can execute code indirectly, such as raw Chrome DevTools Protocol access through [`cdp()`](/api/browser/context#cdp). See [`api.allowExec`](/config/api#api-allowexec) for more information. diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index 53f294949055..2812c89d88a0 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -537,19 +537,11 @@ export class PlaywrightBrowserProvider implements BrowserProvider { const page = this.getPage(sessionid) const cdp = await page.context().newCDPSession(page) return { - send(method, params) { - return cdp.send(method as any, params) - }, - on(event, listener) { - return cdp.on(event as any, listener) - }, - off(event, listener) { - return cdp.off(event as any, listener) - }, - once(event, listener) { - return cdp.once(event as any, listener) - }, - } + send: cdp.send.bind(cdp), + on: cdp.on.bind(cdp), + off: cdp.off.bind(cdp), + once: cdp.once.bind(cdp), + } as any // overloaded CDPSession type is too tricky in monorepo } async close(): Promise { diff --git a/packages/browser/src/node/commands/coverage.ts b/packages/browser/src/node/commands/coverage.ts new file mode 100644 index 000000000000..1af9c4f1ea0e --- /dev/null +++ b/packages/browser/src/node/commands/coverage.ts @@ -0,0 +1,16 @@ +import type { BrowserCommand } from 'vitest/node' +import type { BrowserServerCDPHandler } from '../cdp' + +export const _startV8Coverage: BrowserCommand<[]> = async (context) => { + const session: BrowserServerCDPHandler = await context.__ensureCDPHandler() + await session.send('Profiler.enable') + await session.send('Profiler.startPreciseCoverage', { + callCount: true, + detailed: true, + }) +} + +export const _takeV8Coverage: BrowserCommand<[]> = async (context) => { + const session: BrowserServerCDPHandler = await context.__ensureCDPHandler() + return session.send('Profiler.takePreciseCoverage') +} diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 21bbe8a367dc..bcd6839618b0 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -1,3 +1,4 @@ +import { _startV8Coverage, _takeV8Coverage } from './coverage' import { _fileInfo, readFile, @@ -13,6 +14,8 @@ export default { removeFile: removeFile as typeof removeFile, writeFile: writeFile as typeof writeFile, // private commands + __vitest_startV8Coverage: _startV8Coverage as typeof _startV8Coverage, + __vitest_takeV8Coverage: _takeV8Coverage as typeof _takeV8Coverage, __vitest_markTrace: _markTrace as typeof _markTrace, __vitest_groupTraceStart: _groupTraceStart as typeof _groupTraceStart, __vitest_groupTraceEnd: _groupTraceEnd as typeof _groupTraceEnd, diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 80af962ed797..eadf29d8701f 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -130,6 +130,27 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke ) } + function isCdpAllowed(project: TestProject) { + return ( + project.config.api.allowExec + && project.config.browser.api.allowExec + && project.vitest.config.api.allowExec + && project.vitest.config.browser.api.allowExec + && project.config.api.allowWrite + && project.config.browser.api.allowWrite + && project.vitest.config.api.allowWrite + && project.vitest.config.browser.api.allowWrite + ) + } + + function assertCdpAllowed(project: TestProject) { + if (!isCdpAllowed(project)) { + throw new Error( + `Cannot use CDP because browser API write or exec operations are disabled. See https://vitest.dev/config/browser/api.`, + ) + } + } + function setupClient(project: TestProject, rpcId: string, ws: WebSocket) { const mockResolver = new ServerMockResolver(globalServer.vite, { moduleDirectories: project.config?.deps?.moduleDirectories, @@ -304,6 +325,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke ...args, ) }, + __ensureCDPHandler: () => globalServer.ensureCDPHandler(sessionId, rpcId), }, provider.getCommandsContext(sessionId), ) as any as BrowserCommandContext @@ -381,10 +403,12 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke // CDP async sendCdpEvent(sessionId: string, event: string, payload?: Record) { + assertCdpAllowed(project) const cdp = await globalServer.ensureCDPHandler(sessionId, rpcId) return cdp.send(event, payload) }, async trackCdpEvent(sessionId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) { + assertCdpAllowed(project) const cdp = await globalServer.ensureCDPHandler(sessionId, rpcId) cdp[type](event, listenerId) }, diff --git a/packages/coverage-v8/src/browser.ts b/packages/coverage-v8/src/browser.ts index 87ec6e5c3c98..cf5b72c4f764 100644 --- a/packages/coverage-v8/src/browser.ts +++ b/packages/coverage-v8/src/browser.ts @@ -1,13 +1,15 @@ -import type { CDPSession } from '@vitest/browser-playwright' +import type { Profiler } from 'node:inspector' import type { CoverageProviderModule } from 'vitest/node' import type { V8CoverageProvider } from './provider' -import { cdp } from 'vitest/browser' import { loadProvider } from './load-provider' -const session = cdp() as CDPSession let enabled = false -type ScriptCoverage = Awaited>> +type ScriptCoverage = Profiler.TakePreciseCoverageReturnType + +function triggerCommand(command: string, args: any[] = []): Promise { + return (globalThis as any).__vitest_browser_runner__.commands.triggerCommand(command, args) +} const mod: CoverageProviderModule = { async startCoverage() { @@ -17,15 +19,11 @@ const mod: CoverageProviderModule = { enabled = true - await session.send('Profiler.enable') - await session.send('Profiler.startPreciseCoverage', { - callCount: true, - detailed: true, - }) + await triggerCommand('__vitest_startV8Coverage') }, async takeCoverage(): Promise<{ result: any[] }> { - const coverage = await session.send('Profiler.takePreciseCoverage') + const coverage: ScriptCoverage = await triggerCommand('__vitest_takeV8Coverage') const result: typeof coverage.result = [] // Reduce amount of data sent over rpc by doing some early result filtering diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index a439d1ca0165..bbd1da63eca7 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -358,6 +358,18 @@ export interface BrowserCommandContext { name: K, ...args: Parameters ) => ReturnType + /** + * Returns Vitest's cached CDP handler for the current tester RPC connection. + * This works similar to client `cdp()` API. + * + * Unlike `provider.getCDPSession`, this preserves CDP session state across + * multiple command calls from the same browser tester. This matters for + * stateful CDP domains such as `Profiler`, where `startPreciseCoverage` and + * `takePreciseCoverage` must run on the same CDP session. + * + * @internal + */ + __ensureCDPHandler: () => Promise // use `any` since type is messy } export interface BrowserServerStateSession { diff --git a/test/browser/specs/errors.test.ts b/test/browser/specs/errors.test.ts index f0c40b6f84af..379edcaaed96 100644 --- a/test/browser/specs/errors.test.ts +++ b/test/browser/specs/errors.test.ts @@ -2,7 +2,7 @@ import path from 'pathe' import { expect, test } from 'vitest' import { rolldownVersion } from 'vitest/node' import { buildTestProjectTree } from '../../test-utils' -import { instances, runBrowserTests, runInlineBrowserTests } from './utils' +import { instances, provider, runBrowserTests, runInlineBrowserTests } from './utils' test('prints correct unhandled error stack', async () => { const { stderr } = await runBrowserTests({ @@ -218,3 +218,36 @@ test('prints source-mapped stack for optimized dependency', async () => { } } }) + +test.runIf(provider.name === 'playwright')('cannot use cdp if write or exec is disabled', async () => { + const result = await runInlineBrowserTests({ + 'cdp.test.ts': ` + import { expect, test } from 'vitest' + import { cdp, server } from 'vitest/browser' + + test('cdp throws an error', async () => { + await cdp().send('Runtime.evaluate', { expression: '1 + 1' }) + }) + `, + }, { + browser: { + instances: [{ browser: 'chromium' }], + screenshotFailures: false, + api: { + allowExec: false, + allowWrite: false, + }, + }, + }) + expect(result.errorTree({ project: true })).toMatchInlineSnapshot(` + { + "chromium": { + "cdp.test.ts": { + "cdp throws an error": [ + "Cannot use CDP because browser API write or exec operations are disabled. See https://vitest.dev/config/browser/api.", + ], + }, + }, + } + `) +}) diff --git a/test/coverage-test/test/browser-api-permissions.browser.test.ts b/test/coverage-test/test/browser-api-permissions.browser.test.ts new file mode 100644 index 000000000000..c609b1f4f04f --- /dev/null +++ b/test/coverage-test/test/browser-api-permissions.browser.test.ts @@ -0,0 +1,36 @@ +import { expect } from 'vitest' +import { readCoverageMap, runVitest, test } from '../utils' + +test('browser coverage works when browser api write and exec are disabled', async () => { + await runVitest({ + api: { + allowExec: false, + allowWrite: false, + }, + browser: { + api: { + allowExec: false, + allowWrite: false, + }, + }, + include: ['fixtures/test/math.test.ts'], + coverage: { + reporter: 'json', + include: ['fixtures/src/math.ts'], + }, + }) + + const coverageMap = await readCoverageMap() + const fileCoverages = coverageMap.files().map(file => coverageMap.fileCoverageFor(file)) + + expect(fileCoverages).toMatchInlineSnapshot(` + { + "/fixtures/src/math.ts": { + "branches": "0/0 (100%)", + "functions": "1/4 (25%)", + "lines": "1/4 (25%)", + "statements": "1/4 (25%)", + }, + } + `) +})