Skip to content

Commit af88b1f

Browse files
hi-ogawacodex
andauthored
feat(api): add allowWrite and allowExec options to api [backport to v3] (#10445)
Co-authored-by: Codex <noreply@openai.com>
1 parent 5a7d56e commit af88b1f

24 files changed

Lines changed: 341 additions & 20 deletions

File tree

docs/config/index.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,12 +1806,26 @@ Open Vitest UI (WIP)
18061806

18071807
### api
18081808

1809-
- **Type:** `boolean | number`
1809+
- **Type:** `boolean | number | { port?, strictPort?, host?, allowWrite?, allowExec? }`
18101810
- **Default:** `false`
1811-
- **CLI:** `--api`, `--api.port`, `--api.host`, `--api.strictPort`
1811+
- **CLI:** `--api`, `--api.port`, `--api.host`, `--api.strictPort`, `--api.allowWrite`, `--api.allowExec`
18121812

18131813
Listen to port and serve API. When set to true, the default port is 51204
18141814

1815+
#### api.allowWrite {#api-allowwrite}
1816+
1817+
- **Type:** `boolean`
1818+
- **Default:** `true` if API is not exposed to the network, `false` otherwise
1819+
1820+
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.
1821+
1822+
#### api.allowExec {#api-allowexec}
1823+
1824+
- **Type:** `boolean`
1825+
- **Default:** `true` if API is not exposed to the network, `false` otherwise
1826+
1827+
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.
1828+
18151829
### browser <Badge type="warning">experimental</Badge> {#browser}
18161830

18171831
- **Default:** `{ enabled: false }`

docs/guide/browser/commands.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ By default, Vitest uses `utf-8` encoding but you can override it with options.
1717

1818
::: tip
1919
This API follows [`server.fs`](https://vitejs.dev/config/server-options.html#server-fs-allow) limitations for security reasons.
20+
21+
`writeFile` and `removeFile` also require write access through [`browser.api.allowWrite`](/guide/browser/config#browser-api-allowwrite) and [`api.allowWrite`](/config/#api-allowwrite).
2022
:::
2123

2224
```ts

docs/guide/browser/config.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,26 @@ A path to the HTML entry point. Can be relative to the root of the project. This
144144

145145
## browser.api
146146

147-
- **Type:** `number | { port?, strictPort?, host? }`
147+
- **Type:** `number | { port?, strictPort?, host?, allowWrite?, allowExec? }`
148148
- **Default:** `63315`
149-
- **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com`
149+
- **CLI:** `--browser.api=63315`, `--browser.api.port=1234, --browser.api.host=example.com`, `--browser.api.allowWrite`, `--browser.api.allowExec`
150150

151151
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.
152152

153+
### browser.api.allowWrite {#browser-api-allowwrite}
154+
155+
- **Type:** `boolean`
156+
- **Default:** inherited from [`api.allowWrite`](/config/#api-allowwrite)
157+
158+
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.
159+
160+
### browser.api.allowExec {#browser-api-allowexec}
161+
162+
- **Type:** `boolean`
163+
- **Default:** inherited from [`api.allowExec`](/config/#api-allowexec)
164+
165+
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.
166+
153167
## browser.provider {#browser-provider}
154168

155169
- **Type:** `'webdriverio' | 'playwright' | 'preview' | string`

docs/guide/cli-generated.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ Specify which IP addresses the server should listen on. Set this to `0.0.0.0` or
7171

7272
Set to true to exit if port is already in use, instead of automatically trying the next available port
7373

74+
### api.allowExec
75+
76+
- **CLI:** `--api.allowExec`
77+
- **Config:** [api.allowExec](/config/#api-allowexec)
78+
79+
Allow API to execute code. (Be careful when enabling this option in untrusted environments)
80+
81+
### api.allowWrite
82+
83+
- **CLI:** `--api.allowWrite`
84+
- **Config:** [api.allowWrite](/config/#api-allowwrite)
85+
86+
Allow API to edit files. (Be careful when enabling this option in untrusted environments)
87+
7488
### silent
7589

7690
- **CLI:** `--silent [value]`
@@ -355,6 +369,20 @@ Specify which IP addresses the server should listen on. Set this to `0.0.0.0` or
355369

356370
Set to true to exit if port is already in use, instead of automatically trying the next available port
357371

372+
### browser.api.allowExec
373+
374+
- **CLI:** `--browser.api.allowExec`
375+
- **Config:** [browser.api.allowExec](/guide/browser/config#browser-api-allowexec)
376+
377+
Allow API to execute code. (Be careful when enabling this option in untrusted environments)
378+
379+
### browser.api.allowWrite
380+
381+
- **CLI:** `--browser.api.allowWrite`
382+
- **Config:** [browser.api.allowWrite](/guide/browser/config#browser-api-allowwrite)
383+
384+
Allow API to edit files. (Be careful when enabling this option in untrusted environments)
385+
358386
### browser.provider
359387

360388
- **CLI:** `--browser.provider <name>`

packages/browser/src/node/commands/fs.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs, { promises as fsp } from 'node:fs'
44
import { basename, dirname, resolve } from 'node:path'
55
import mime from 'mime/lite'
66
import { isFileServingAllowed } from 'vitest/node'
7+
import { slash } from '../utils'
78

89
function assertFileAccess(path: string, project: TestProject) {
910
if (
@@ -16,11 +17,17 @@ function assertFileAccess(path: string, project: TestProject) {
1617
}
1718
}
1819

20+
function assertWrite(path: string, project: TestProject) {
21+
if (!project.config.browser.api.allowWrite || !project.vitest.config.api.allowWrite) {
22+
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.`)
23+
}
24+
}
25+
1926
export const readFile: BrowserCommand<
2027
Parameters<BrowserCommands['readFile']>
2128
> = async ({ project }, path, options = {}) => {
2229
const filepath = resolve(project.config.root, path)
23-
assertFileAccess(filepath, project)
30+
assertFileAccess(slash(filepath), project)
2431
// never return a Buffer
2532
if (typeof options === 'object' && !options.encoding) {
2633
options.encoding = 'utf-8'
@@ -31,8 +38,9 @@ export const readFile: BrowserCommand<
3138
export const writeFile: BrowserCommand<
3239
Parameters<BrowserCommands['writeFile']>
3340
> = async ({ project }, path, data, options) => {
41+
assertWrite(path, project)
3442
const filepath = resolve(project.config.root, path)
35-
assertFileAccess(filepath, project)
43+
assertFileAccess(slash(filepath), project)
3644
const dir = dirname(filepath)
3745
if (!fs.existsSync(dir)) {
3846
await fsp.mkdir(dir, { recursive: true })
@@ -43,14 +51,15 @@ export const writeFile: BrowserCommand<
4351
export const removeFile: BrowserCommand<
4452
Parameters<BrowserCommands['removeFile']>
4553
> = async ({ project }, path) => {
54+
assertWrite(path, project)
4655
const filepath = resolve(project.config.root, path)
47-
assertFileAccess(filepath, project)
56+
assertFileAccess(slash(filepath), project)
4857
await fsp.rm(filepath)
4958
}
5059

5160
export const _fileInfo: BrowserCommand<[path: string, encoding: BufferEncoding]> = async ({ project }, path, encoding) => {
5261
const filepath = resolve(project.config.root, path)
53-
assertFileAccess(filepath, project)
62+
assertFileAccess(slash(filepath), project)
5463
const content = await fsp.readFile(filepath, encoding || 'base64')
5564
return {
5665
content,

packages/browser/src/node/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
354354
const api = resolveApiServerConfig(
355355
viteConfig.test?.browser || {},
356356
defaultPort,
357+
parentServer.vitest.config.api,
358+
parentServer.vitest.logger,
357359
)
358360

359361
viteConfig.server = {

packages/browser/src/node/rpc.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { parse, stringify } from 'flatted'
1515
import { dirname, join } from 'pathe'
1616
import { createDebugger, isFileServingAllowed, isValidApiRequest } from 'vitest/node'
1717
import { WebSocketServer } from 'ws'
18+
import { slash } from './utils'
1819

1920
const debug = createDebugger('vitest:browser:api')
2021

@@ -111,13 +112,22 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
111112
}
112113

113114
function checkFileAccess(path: string) {
114-
if (!isFileServingAllowed(path, vite)) {
115+
if (!isFileServingAllowed(slash(path), vite)) {
115116
throw new Error(
116117
`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`,
117118
)
118119
}
119120
}
120121

122+
function canWrite(project: TestProject) {
123+
return (
124+
project.config.browser.api.allowWrite
125+
&& project.vitest.config.browser.api.allowWrite
126+
&& project.config.api.allowWrite
127+
&& project.vitest.config.api.allowWrite
128+
)
129+
}
130+
121131
function setupClient(project: TestProject, rpcId: string, ws: WebSocket) {
122132
const mockResolver = new ServerMockResolver(globalServer.vite, {
123133
moduleDirectories: project.config.server?.deps?.moduleDirectories,
@@ -191,11 +201,23 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
191201
},
192202
async saveSnapshotFile(id, content) {
193203
checkFileAccess(id)
204+
if (!canWrite(project)) {
205+
vitest.logger.error(
206+
`[vitest] Cannot save snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`,
207+
)
208+
return
209+
}
194210
await fs.mkdir(dirname(id), { recursive: true })
195211
return fs.writeFile(id, content, 'utf-8')
196212
},
197213
async removeSnapshotFile(id) {
198214
checkFileAccess(id)
215+
if (!canWrite(project)) {
216+
vitest.logger.error(
217+
`[vitest] Cannot remove snapshot file "${id}". File writing is disabled because server is exposed to the internet, see https://vitest.dev/config/browser/api.`,
218+
)
219+
return
220+
}
199221
if (!existsSync(id)) {
200222
throw new Error(`Snapshot file "${id}" does not exist.`)
201223
}

packages/ui/client/components/Navigation.vue

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { File } from 'vitest'
33
import { Tooltip as VueTooltip } from 'floating-vue'
44
import { isDark, toggleDark } from '~/composables'
5-
import { client, isReport, runAll, runFiles } from '~/composables/client'
5+
import { client, config, isReport, runAll, runFiles } from '~/composables/client'
66
import { explorerTree } from '~/composables/explorer'
77
import { initialized, shouldShowExpandAll } from '~/composables/explorer/state'
88
import {
@@ -23,6 +23,10 @@ function updateSnapshot() {
2323
const toggleMode = computed(() => isDark.value ? 'light' : 'dark')
2424
2525
async function onRunAll(files?: File[]) {
26+
if (config.value.api?.allowExec === false) {
27+
return
28+
}
29+
2630
if (coverageEnabled.value) {
2731
disableCoverage.value = true
2832
await nextTick()
@@ -46,6 +50,13 @@ function collapseTests() {
4650
function expandTests() {
4751
explorerTree.expandAllNodes()
4852
}
53+
54+
function getRerunTooltip(filteredFiles: File[] | undefined) {
55+
if (config.value.api?.allowExec === false) {
56+
return 'Cannot run tests when `api.allowExec` is `false`. Did you expose UI to the internet?'
57+
}
58+
return filteredFiles ? (filteredFiles.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all'
59+
}
4960
</script>
5061

5162
<template>
@@ -110,17 +121,18 @@ function expandTests() {
110121
@click="showCoverage()"
111122
/>
112123
<IconButton
113-
v-if="(explorerTree.summary.failedSnapshot && !isReport)"
124+
v-if="(explorerTree.summary.failedSnapshot && !isReport && config.api?.allowExec && config.api?.allowWrite)"
114125
v-tooltip.bottom="'Update all failed snapshot(s)'"
115126
icon="i-carbon:result-old"
116127
:disabled="!explorerTree.summary.failedSnapshotEnabled"
117128
@click="explorerTree.summary.failedSnapshotEnabled && updateSnapshot()"
118129
/>
119130
<IconButton
120131
v-if="!isReport"
121-
v-tooltip.bottom="filteredFiles ? (filteredFiles.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all'"
122-
:disabled="filteredFiles?.length === 0"
132+
v-tooltip.bottom="getRerunTooltip(filteredFiles)"
133+
:disabled="filteredFiles?.length === 0 || !config.api?.allowExec"
123134
icon="i-carbon:play"
135+
data-testid="btn-run-all"
124136
@click="onRunAll(filteredFiles)"
125137
/>
126138
<IconButton

packages/ui/client/components/explorer/ExplorerItem.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Task, TaskState } from '@vitest/runner'
33
import type { TaskTreeNodeType } from '~/composables/explorer/types'
44
import { Tooltip as VueTooltip } from 'floating-vue'
55
import { nextTick } from 'vue'
6-
import { client, isReport, runFiles, runTask } from '~/composables/client'
6+
import { client, config, isReport, runFiles, runTask } from '~/composables/client'
77
import { showTaskSource } from '~/composables/codemirror'
88
import { explorerTree } from '~/composables/explorer'
99
import { hasFailedSnapshot } from '~/composables/explorer/collector'
@@ -118,6 +118,9 @@ const gridStyles = computed(() => {
118118
})
119119
120120
const runButtonTitle = computed(() => {
121+
if (config.value.api?.allowExec === false) {
122+
return 'Cannot run tests when `api.allowExec` is `false`. Did you expose UI to the internet?'
123+
}
121124
return type === 'file'
122125
? 'Run current file'
123126
: type === 'suite'
@@ -195,7 +198,7 @@ const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor
195198
</div>
196199
<div gap-1 justify-end flex-grow-1 pl-1 class="test-actions">
197200
<IconAction
198-
v-if="!isReport && failedSnapshot"
201+
v-if="!isReport && failedSnapshot && config.api?.allowExec && config.api?.allowWrite"
199202
v-tooltip.bottom="'Fix failed snapshot(s)'"
200203
data-testid="btn-fix-snapshot"
201204
title="Fix failed snapshot(s)"
@@ -232,6 +235,7 @@ const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor
232235
:title="runButtonTitle"
233236
icon="i-carbon:play-filled-alt"
234237
text-green5
238+
:disabled="config.api?.allowExec === false"
235239
@click.prevent.stop="onRun(task)"
236240
/>
237241
</div>

packages/ui/client/components/views/ViewEditor.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type CodeMirror from 'codemirror'
44
import type { ErrorWithDiff, File, TestAnnotation, TestError } from 'vitest'
55
import { createTooltip, destroyTooltip } from 'floating-vue'
66
import { getAttachmentUrl, sanitizeFilePath } from '~/composables/attachments'
7-
import { client, isReport } from '~/composables/client'
7+
import { client, config, isReport } from '~/composables/client'
88
import { finished } from '~/composables/client/state'
99
import { codemirrorRef } from '~/composables/codemirror'
1010
import { openInEditor } from '~/composables/error'
@@ -382,7 +382,7 @@ onBeforeUnmount(clearListeners)
382382
ref="editor"
383383
v-model="code"
384384
h-full
385-
v-bind="{ lineNumbers: true, readOnly: isReport, saving }"
385+
v-bind="{ lineNumbers: true, readOnly: isReport || !config.api?.allowWrite, saving }"
386386
:mode="ext"
387387
data-testid="code-mirror"
388388
@save="onSave"

0 commit comments

Comments
 (0)