diff --git a/docs/config/index.md b/docs/config/index.md index 433aaaf5b..175ef1e35 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -1806,12 +1806,26 @@ Open Vitest UI (WIP) ### api -- **Type:** `boolean | number` +- **Type:** `boolean | number | { port?, strictPort?, host?, allowWrite?, allowExec? }` - **Default:** `false` -- **CLI:** `--api`, `--api.port`, `--api.host`, `--api.strictPort` +- **CLI:** `--api`, `--api.port`, `--api.host`, `--api.strictPort`, `--api.allowWrite`, `--api.allowExec` Listen to port and serve API. When set to true, the default port is 51204 +#### api.allowWrite {#api-allowwrite} + +- **Type:** `boolean` +- **Default:** `true` if API is not exposed to the network, `false` otherwise + +Allows API clients to write files, including updating test files from the UI. If `api.host` is set to anything other than `localhost` or `127.0.0.1`, Vitest disables write operations by default. + +#### api.allowExec {#api-allowexec} + +- **Type:** `boolean` +- **Default:** `true` if API is not exposed to the network, `false` otherwise + +Allows API clients to run tests. If `api.host` is exposed to the network and write/exec operations are enabled, anyone who can reach the API server can run arbitrary code on your machine. + ### browser experimental {#browser} - **Default:** `{ enabled: false }` diff --git a/docs/guide/browser/commands.md b/docs/guide/browser/commands.md index c28cff3de..7a5bf02ed 100644 --- a/docs/guide/browser/commands.md +++ b/docs/guide/browser/commands.md @@ -17,6 +17,8 @@ By default, Vitest uses `utf-8` encoding but you can override it with options. ::: tip This API follows [`server.fs`](https://vitejs.dev/config/server-options.html#server-fs-allow) limitations for security reasons. + +`writeFile` and `removeFile` also require write access through [`browser.api.allowWrite`](/guide/browser/config#browser-api-allowwrite) and [`api.allowWrite`](/config/#api-allowwrite). ::: ```ts diff --git a/docs/guide/browser/config.md b/docs/guide/browser/config.md index 9b73e1797..9f03fed9b 100644 --- a/docs/guide/browser/config.md +++ b/docs/guide/browser/config.md @@ -144,12 +144,26 @@ A path to the HTML entry point. Can be relative to the root of the project. This ## browser.api -- **Type:** `number | { port?, strictPort?, host? }` +- **Type:** `number | { port?, strictPort?, host?, allowWrite?, allowExec? }` - **Default:** `63315` -- **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com` +- **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com`, `--browser.api.allowWrite`, `--browser.api.allowExec` Configure options for Vite server that serves code in the browser. Does not affect [`test.api`](#api) option. By default, Vitest assigns port `63315` to avoid conflicts with the development server, allowing you to run both in parallel. +### browser.api.allowWrite {#browser-api-allowwrite} + +- **Type:** `boolean` +- **Default:** inherited from [`api.allowWrite`](/config/#api-allowwrite) + +Allows browser API clients to write files, including snapshots and browser command writes. If `browser.api.host` is set to anything other than `localhost` or `127.0.0.1`, Vitest disables write operations by default unless this option or [`api.allowWrite`](/config/#api-allowwrite) is explicitly enabled. + +### browser.api.allowExec {#browser-api-allowexec} + +- **Type:** `boolean` +- **Default:** inherited from [`api.allowExec`](/config/#api-allowexec) + +Allows browser API clients to run tests from the UI. If `browser.api.host` is exposed to the network and write/exec operations are enabled, anyone who can reach the browser API server can run arbitrary code on your machine. + ## browser.provider {#browser-provider} - **Type:** `'webdriverio' | 'playwright' | 'preview' | string` diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index a7ab176d6..5d52a15d7 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -71,6 +71,20 @@ Specify which IP addresses the server should listen on. Set this to `0.0.0.0` or Set to true to exit if port is already in use, instead of automatically trying the next available port +### api.allowExec + +- **CLI:** `--api.allowExec` +- **Config:** [api.allowExec](/config/#api-allowexec) + +Allow API to execute code. (Be careful when enabling this option in untrusted environments) + +### api.allowWrite + +- **CLI:** `--api.allowWrite` +- **Config:** [api.allowWrite](/config/#api-allowwrite) + +Allow API to edit files. (Be careful when enabling this option in untrusted environments) + ### silent - **CLI:** `--silent [value]` @@ -355,6 +369,20 @@ Specify which IP addresses the server should listen on. Set this to `0.0.0.0` or Set to true to exit if port is already in use, instead of automatically trying the next available port +### browser.api.allowExec + +- **CLI:** `--browser.api.allowExec` +- **Config:** [browser.api.allowExec](/guide/browser/config#browser-api-allowexec) + +Allow API to execute code. (Be careful when enabling this option in untrusted environments) + +### browser.api.allowWrite + +- **CLI:** `--browser.api.allowWrite` +- **Config:** [browser.api.allowWrite](/guide/browser/config#browser-api-allowwrite) + +Allow API to edit files. (Be careful when enabling this option in untrusted environments) + ### browser.provider - **CLI:** `--browser.provider ` diff --git a/packages/browser/src/node/commands/fs.ts b/packages/browser/src/node/commands/fs.ts index bba622517..dc1981029 100644 --- a/packages/browser/src/node/commands/fs.ts +++ b/packages/browser/src/node/commands/fs.ts @@ -4,6 +4,7 @@ import fs, { promises as fsp } from 'node:fs' import { basename, dirname, resolve } from 'node:path' import mime from 'mime/lite' import { isFileServingAllowed } from 'vitest/node' +import { slash } from '../utils' function assertFileAccess(path: string, project: TestProject) { if ( @@ -16,11 +17,17 @@ function assertFileAccess(path: string, project: TestProject) { } } +function assertWrite(path: string, project: TestProject) { + if (!project.config.browser.api.allowWrite || !project.vitest.config.api.allowWrite) { + throw new Error(`Cannot modify file "${path}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`) + } +} + export const readFile: BrowserCommand< Parameters > = async ({ project }, path, options = {}) => { const filepath = resolve(project.config.root, path) - assertFileAccess(filepath, project) + assertFileAccess(slash(filepath), project) // never return a Buffer if (typeof options === 'object' && !options.encoding) { options.encoding = 'utf-8' @@ -31,8 +38,9 @@ export const readFile: BrowserCommand< export const writeFile: BrowserCommand< Parameters > = async ({ project }, path, data, options) => { + assertWrite(path, project) const filepath = resolve(project.config.root, path) - assertFileAccess(filepath, project) + assertFileAccess(slash(filepath), project) const dir = dirname(filepath) if (!fs.existsSync(dir)) { await fsp.mkdir(dir, { recursive: true }) @@ -43,14 +51,15 @@ export const writeFile: BrowserCommand< export const removeFile: BrowserCommand< Parameters > = async ({ project }, path) => { + assertWrite(path, project) const filepath = resolve(project.config.root, path) - assertFileAccess(filepath, project) + assertFileAccess(slash(filepath), project) await fsp.rm(filepath) } export const _fileInfo: BrowserCommand<[path: string, encoding: BufferEncoding]> = async ({ project }, path, encoding) => { const filepath = resolve(project.config.root, path) - assertFileAccess(filepath, project) + assertFileAccess(slash(filepath), project) const content = await fsp.readFile(filepath, encoding || 'base64') return { content, diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 7ae6e13ef..fad2ed0d8 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -354,6 +354,8 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { const api = resolveApiServerConfig( viteConfig.test?.browser || {}, defaultPort, + parentServer.vitest.config.api, + parentServer.vitest.logger, ) viteConfig.server = { diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 7619c5f0f..358ac355f 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -15,6 +15,7 @@ import { parse, stringify } from 'flatted' import { dirname, join } from 'pathe' import { createDebugger, isFileServingAllowed, isValidApiRequest } from 'vitest/node' import { WebSocketServer } from 'ws' +import { slash } from './utils' const debug = createDebugger('vitest:browser:api') @@ -111,13 +112,22 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke } function checkFileAccess(path: string) { - if (!isFileServingAllowed(path, vite)) { + if (!isFileServingAllowed(slash(path), vite)) { throw new Error( `Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`, ) } } + function canWrite(project: TestProject) { + return ( + project.config.browser.api.allowWrite + && project.vitest.config.browser.api.allowWrite + && project.config.api.allowWrite + && project.vitest.config.api.allowWrite + ) + } + function setupClient(project: TestProject, rpcId: string, ws: WebSocket) { const mockResolver = new ServerMockResolver(globalServer.vite, { moduleDirectories: project.config.server?.deps?.moduleDirectories, @@ -191,11 +201,23 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke }, async saveSnapshotFile(id, content) { checkFileAccess(id) + if (!canWrite(project)) { + vitest.logger.error( + `[vitest] Cannot save snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`, + ) + return + } await fs.mkdir(dirname(id), { recursive: true }) return fs.writeFile(id, content, 'utf-8') }, async removeSnapshotFile(id) { checkFileAccess(id) + if (!canWrite(project)) { + vitest.logger.error( + `[vitest] Cannot remove snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`, + ) + return + } if (!existsSync(id)) { throw new Error(`Snapshot file "${id}" does not exist.`) } diff --git a/packages/ui/client/components/Navigation.vue b/packages/ui/client/components/Navigation.vue index 1c4abd735..cf0f249d9 100644 --- a/packages/ui/client/components/Navigation.vue +++ b/packages/ui/client/components/Navigation.vue @@ -2,7 +2,7 @@ import type { File } from 'vitest' import { Tooltip as VueTooltip } from 'floating-vue' import { isDark, toggleDark } from '~/composables' -import { client, isReport, runAll, runFiles } from '~/composables/client' +import { client, config, isReport, runAll, runFiles } from '~/composables/client' import { explorerTree } from '~/composables/explorer' import { initialized, shouldShowExpandAll } from '~/composables/explorer/state' import { @@ -23,6 +23,10 @@ function updateSnapshot() { const toggleMode = computed(() => isDark.value ? 'light' : 'dark') async function onRunAll(files?: File[]) { + if (config.value.api?.allowExec === false) { + return + } + if (coverageEnabled.value) { disableCoverage.value = true await nextTick() @@ -46,6 +50,13 @@ function collapseTests() { function expandTests() { explorerTree.expandAllNodes() } + +function getRerunTooltip(filteredFiles: File[] | undefined) { + if (config.value.api?.allowExec === false) { + return 'Cannot run tests when `api.allowExec` is `false`. Did you expose UI to the internet?' + } + return filteredFiles ? (filteredFiles.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all' +}