Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Badge type="warning">experimental</Badge> {#browser}

- **Default:** `{ enabled: false }`
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/browser/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions docs/guide/browser/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
28 changes: 28 additions & 0 deletions docs/guide/cli-generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
Expand Down Expand Up @@ -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 <name>`
Expand Down
17 changes: 13 additions & 4 deletions packages/browser/src/node/commands/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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<BrowserCommands['readFile']>
> = 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'
Expand All @@ -31,8 +38,9 @@ export const readFile: BrowserCommand<
export const writeFile: BrowserCommand<
Parameters<BrowserCommands['writeFile']>
> = 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 })
Expand All @@ -43,14 +51,15 @@ export const writeFile: BrowserCommand<
export const removeFile: BrowserCommand<
Parameters<BrowserCommands['removeFile']>
> = 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,
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
24 changes: 23 additions & 1 deletion packages/browser/src/node/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.`)
}
Expand Down
20 changes: 16 additions & 4 deletions packages/ui/client/components/Navigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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'
}
</script>

<template>
Expand Down Expand Up @@ -110,17 +121,18 @@ function expandTests() {
@click="showCoverage()"
/>
<IconButton
v-if="(explorerTree.summary.failedSnapshot && !isReport)"
v-if="(explorerTree.summary.failedSnapshot && !isReport && config.api?.allowExec && config.api?.allowWrite)"
v-tooltip.bottom="'Update all failed snapshot(s)'"
icon="i-carbon:result-old"
:disabled="!explorerTree.summary.failedSnapshotEnabled"
@click="explorerTree.summary.failedSnapshotEnabled && updateSnapshot()"
/>
<IconButton
v-if="!isReport"
v-tooltip.bottom="filteredFiles ? (filteredFiles.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all'"
:disabled="filteredFiles?.length === 0"
v-tooltip.bottom="getRerunTooltip(filteredFiles)"
:disabled="filteredFiles?.length === 0 || !config.api?.allowExec"
icon="i-carbon:play"
data-testid="btn-run-all"
@click="onRunAll(filteredFiles)"
/>
<IconButton
Expand Down
8 changes: 6 additions & 2 deletions packages/ui/client/components/explorer/ExplorerItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Task, TaskState } from '@vitest/runner'
import type { TaskTreeNodeType } from '~/composables/explorer/types'
import { Tooltip as VueTooltip } from 'floating-vue'
import { nextTick } from 'vue'
import { client, isReport, runFiles, runTask } from '~/composables/client'
import { client, config, isReport, runFiles, runTask } from '~/composables/client'
import { showTaskSource } from '~/composables/codemirror'
import { explorerTree } from '~/composables/explorer'
import { hasFailedSnapshot } from '~/composables/explorer/collector'
Expand Down Expand Up @@ -118,6 +118,9 @@ const gridStyles = computed(() => {
})

const runButtonTitle = computed(() => {
if (config.value.api?.allowExec === false) {
return 'Cannot run tests when `api.allowExec` is `false`. Did you expose UI to the internet?'
}
return type === 'file'
? 'Run current file'
: type === 'suite'
Expand Down Expand Up @@ -195,7 +198,7 @@ const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor
</div>
<div gap-1 justify-end flex-grow-1 pl-1 class="test-actions">
<IconAction
v-if="!isReport && failedSnapshot"
v-if="!isReport && failedSnapshot && config.api?.allowExec && config.api?.allowWrite"
v-tooltip.bottom="'Fix failed snapshot(s)'"
data-testid="btn-fix-snapshot"
title="Fix failed snapshot(s)"
Expand Down Expand Up @@ -232,6 +235,7 @@ const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor
:title="runButtonTitle"
icon="i-carbon:play-filled-alt"
text-green5
:disabled="config.api?.allowExec === false"
@click.prevent.stop="onRun(task)"
/>
</div>
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/client/components/views/ViewEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type CodeMirror from 'codemirror'
import type { ErrorWithDiff, File, TestAnnotation, TestError } from 'vitest'
import { createTooltip, destroyTooltip } from 'floating-vue'
import { getAttachmentUrl, sanitizeFilePath } from '~/composables/attachments'
import { client, isReport } from '~/composables/client'
import { client, config, isReport } from '~/composables/client'
import { finished } from '~/composables/client/state'
import { codemirrorRef } from '~/composables/codemirror'
import { openInEditor } from '~/composables/error'
Expand Down Expand Up @@ -382,7 +382,7 @@ onBeforeUnmount(clearListeners)
ref="editor"
v-model="code"
h-full
v-bind="{ lineNumbers: true, readOnly: isReport, saving }"
v-bind="{ lineNumbers: true, readOnly: isReport || !config.api?.allowWrite, saving }"
:mode="ext"
data-testid="code-mirror"
@save="onSave"
Expand Down
12 changes: 12 additions & 0 deletions packages/vitest/src/api/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,21 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void {
`Test file "${id}" was not registered, so it cannot be updated using the API.`,
)
}
if (!ctx.config.api.allowWrite) {
return
}
return fs.writeFile(id, content, 'utf-8')
},
async rerun(files, resetTestNamePattern) {
if (!ctx.config.api.allowExec) {
return
}
await ctx.rerunFiles(files, undefined, true, resetTestNamePattern)
},
async rerunTask(id) {
if (!ctx.config.api.allowExec) {
return
}
await ctx.rerunTask(id)
},
getConfig() {
Expand Down Expand Up @@ -111,6 +120,9 @@ export function setup(ctx: Vitest, _server?: ViteDevServer): void {
return getModuleGraph(ctx, project, id, browser)
},
async updateSnapshot(file?: File) {
if (!ctx.config.api.allowExec || !ctx.config.api.allowWrite) {
return
}
if (!file) {
await ctx.updateSnapshot()
}
Expand Down
6 changes: 6 additions & 0 deletions packages/vitest/src/node/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ const apiConfig: (port: number) => CLIOptions<ApiConfig> = (port: number) => ({
description:
'Set to true to exit if port is already in use, instead of automatically trying the next available port',
},
allowExec: {
description: 'Allow API to execute code. (Be careful when enabling this option in untrusted environments)',
},
allowWrite: {
description: 'Allow API to edit files. (Be careful when enabling this option in untrusted environments)',
},
middlewareMode: null,
})

Expand Down
Loading
Loading