{
})
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'
@@ -195,7 +198,7 @@ const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor
getProjectTextColor(projectNameColor
:title="runButtonTitle"
icon="i-carbon:play-filled-alt"
text-green5
+ :disabled="config.api?.allowExec === false"
@click.prevent.stop="onRun(task)"
/>
diff --git a/packages/ui/client/components/views/ViewEditor.vue b/packages/ui/client/components/views/ViewEditor.vue
index d591f8919..f753faf8f 100644
--- a/packages/ui/client/components/views/ViewEditor.vue
+++ b/packages/ui/client/components/views/ViewEditor.vue
@@ -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'
@@ -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"
diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts
index d0d849952..30f4777c9 100644
--- a/packages/vitest/src/api/setup.ts
+++ b/packages/vitest/src/api/setup.ts
@@ -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() {
@@ -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()
}
diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts
index 18d559a2f..a7cca4e21 100644
--- a/packages/vitest/src/node/cli/cli-config.ts
+++ b/packages/vitest/src/node/cli/cli-config.ts
@@ -52,6 +52,12 @@ const apiConfig: (port: number) => CLIOptions = (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,
})
diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts
index 7ff31eefc..09b262ed4 100644
--- a/packages/vitest/src/node/config/resolveConfig.ts
+++ b/packages/vitest/src/node/config/resolveConfig.ts
@@ -1,5 +1,6 @@
import type { ResolvedConfig as ResolvedViteConfig } from 'vite'
import type { Vitest } from '../core'
+import type { Logger } from '../logger'
import type { BenchmarkBuiltinReporters } from '../reporters'
import type { ResolvedBrowserOptions } from '../types/browser'
import type {
@@ -59,6 +60,8 @@ function parseInspector(inspect: string | undefined | boolean | number) {
export function resolveApiServerConfig(
options: Options,
defaultPort: number,
+ parentApi?: ApiConfig,
+ logger?: Logger,
): ApiConfig | undefined {
let api: ApiConfig | undefined
@@ -98,6 +101,22 @@ export function resolveApiServerConfig(
api = { middlewareMode: true }
}
+ if (!api.middlewareMode && api.host && api.host !== 'localhost' && api.host !== '127.0.0.1') {
+ if (parentApi && api.allowWrite == null && api.allowExec == null) {
+ logger?.error(
+ c.yellow(
+ `${c.yellowBright(' WARNING ')} API server is exposed to network, disabling write and exec operations by default for security reasons. This can cause some APIs to not work as expected. Set \`browser.api.allowExec\` manually to hide this warning. See https://vitest.dev/config/browser/api for more details.`,
+ ),
+ )
+ }
+ api.allowWrite ??= parentApi?.allowWrite ?? false
+ api.allowExec ??= parentApi?.allowExec ?? false
+ }
+ else {
+ api.allowWrite ??= parentApi?.allowWrite ?? true
+ api.allowExec ??= parentApi?.allowExec ?? true
+ }
+
return api
}
@@ -838,6 +857,8 @@ export function resolveConfig(
resolved.browser.api = resolveApiServerConfig(
resolved.browser,
defaultBrowserPort,
+ resolved.api,
+ logger,
) || {
port: defaultBrowserPort,
}
diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts
index b67b18b0a..fe24109c0 100644
--- a/packages/vitest/src/node/config/serializeConfig.ts
+++ b/packages/vitest/src/node/config/serializeConfig.ts
@@ -1,5 +1,5 @@
import type { ResolvedConfig as ViteConfig } from 'vite'
-import type { ResolvedConfig, SerializedConfig } from '../types/config'
+import type { ApiConfig, ResolvedConfig, SerializedConfig } from '../types/config'
export function serializeConfig(
config: ResolvedConfig,
@@ -37,6 +37,12 @@ export function serializeConfig(
pool: config.pool,
expect: config.expect,
snapshotSerializers: config.snapshotSerializers,
+ api: ((api: ApiConfig | undefined) => {
+ return {
+ allowExec: api?.allowExec,
+ allowWrite: api?.allowWrite,
+ }
+ })(config.browser.enabled ? config.browser.api : config.api),
// TODO: non serializable function?
diff: config.diff,
retry: config.retry,
diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts
index 53587926c..72b58544d 100644
--- a/packages/vitest/src/node/types/config.ts
+++ b/packages/vitest/src/node/types/config.ts
@@ -45,7 +45,20 @@ export type CSSModuleScopeStrategy = 'stable' | 'scoped' | 'non-scoped'
export type ApiConfig = Pick<
ServerOptions,
'port' | 'strictPort' | 'host' | 'middlewareMode'
->
+> & {
+ /**
+ * Allow any write operations from the API server.
+ *
+ * @default true if API is not exposed to the network, false otherwise
+ */
+ allowWrite?: boolean
+ /**
+ * Allow running test files via the API.
+ *
+ * @default true if API is not exposed to the network, false otherwise
+ */
+ allowExec?: boolean
+}
export type { EnvironmentOptions, HappyDOMOptions, JSDOMOptions }
diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts
index 0383f144e..db188ba01 100644
--- a/packages/vitest/src/runtime/config.ts
+++ b/packages/vitest/src/runtime/config.ts
@@ -99,6 +99,10 @@ export interface SerializedConfig {
showDiff?: boolean
truncateThreshold?: number
} | undefined
+ api: {
+ allowExec: boolean | undefined
+ allowWrite: boolean | undefined
+ }
diff: string | SerializedDiffOptions | undefined
retry: number
includeTaskLocation: boolean | undefined
diff --git a/test/browser/fixtures/errors-fs/__snapshots__/basic.test.js.snap b/test/browser/fixtures/errors-fs/__snapshots__/basic.test.js.snap
new file mode 100644
index 000000000..a5429dbf3
--- /dev/null
+++ b/test/browser/fixtures/errors-fs/__snapshots__/basic.test.js.snap
@@ -0,0 +1 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
diff --git a/test/browser/fixtures/errors-fs/basic.test.js b/test/browser/fixtures/errors-fs/basic.test.js
new file mode 100644
index 000000000..b24e399cb
--- /dev/null
+++ b/test/browser/fixtures/errors-fs/basic.test.js
@@ -0,0 +1,5 @@
+import { expect, test } from 'vitest'
+
+test('basic test', () => {
+ expect(1 + 1).toBe(2)
+})
diff --git a/test/browser/fixtures/errors-fs/fs-commands.test.ts b/test/browser/fixtures/errors-fs/fs-commands.test.ts
new file mode 100644
index 000000000..9f80360cb
--- /dev/null
+++ b/test/browser/fixtures/errors-fs/fs-commands.test.ts
@@ -0,0 +1,18 @@
+import { server } from '@vitest/browser/context'
+import { describe, expect, test } from 'vitest'
+
+const { removeFile, writeFile } = server.commands
+
+describe('fs security', () => {
+ test('fs writeFile throws an error', async () => {
+ await writeFile('/test-file.txt', 'Hello World')
+ })
+
+ test('fs removeFile throws an error', async () => {
+ await removeFile('/test-file.txt')
+ })
+
+ test('snapshot saves are not saved', () => {
+ expect('snapshot content').toMatchSnapshot()
+ })
+})
diff --git a/test/browser/fixtures/errors-fs/vitest.config.ts b/test/browser/fixtures/errors-fs/vitest.config.ts
new file mode 100644
index 000000000..53033990b
--- /dev/null
+++ b/test/browser/fixtures/errors-fs/vitest.config.ts
@@ -0,0 +1,19 @@
+import { fileURLToPath } from 'node:url'
+import { defineConfig } from 'vitest/config'
+import { instances, provider } from '../../settings'
+
+export default defineConfig({
+ cacheDir: fileURLToPath(new URL('./node_modules/.vite', import.meta.url)),
+ test: {
+ browser: {
+ enabled: true,
+ provider,
+ instances,
+ headless: true,
+ api: {
+ allowExec: false,
+ allowWrite: false,
+ },
+ },
+ },
+})
diff --git a/test/browser/specs/errors-fs.test.ts b/test/browser/specs/errors-fs.test.ts
new file mode 100644
index 000000000..2ff3cba08
--- /dev/null
+++ b/test/browser/specs/errors-fs.test.ts
@@ -0,0 +1,23 @@
+import { resolve } from 'pathe'
+import { expect, test } from 'vitest'
+import { instances, runBrowserTests } from './utils'
+
+// v3 backport of
+// https://github.com/vitest-dev/vitest/blob/af993b66b4e5ba9498cb658a86a74a2c0416b75e/test/browser/specs/errors.test.ts#L73
+test('cannot use fs commands if write is disabled', async () => {
+ const root = './fixtures/errors-fs'
+ const { stderr } = await runBrowserTests({
+ root,
+ update: true,
+ })
+
+ const errors = stderr.split('\n').filter(line => line.includes('Cannot modify file "/test-file.txt".'))
+ expect(errors).toHaveLength(2 * instances.length)
+
+ expect(stderr).toContain(
+ `Cannot save snapshot file "${resolve(process.cwd(), root, './__snapshots__/fs-commands.test.ts.snap')}". File writing is disabled because server is exposed to the internet`,
+ )
+ expect(stderr).toContain(
+ `Cannot remove snapshot file "${resolve(process.cwd(), root, './__snapshots__/basic.test.js.snap')}". File writing is disabled because server is exposed to the internet`,
+ )
+})
diff --git a/test/config/test/browser-configs.test.ts b/test/config/test/browser-configs.test.ts
index d4ae0eafb..e35ace7e6 100644
--- a/test/config/test/browser-configs.test.ts
+++ b/test/config/test/browser-configs.test.ts
@@ -5,7 +5,7 @@ import crypto from 'node:crypto'
import { resolve } from 'pathe'
import { describe, expect, onTestFinished, test } from 'vitest'
import { createVitest } from 'vitest/node'
-import { runVitestCli, useFS } from '../../test-utils'
+import { runVitest, runVitestCli, useFS } from '../../test-utils'
async function vitest(cliOptions: UserConfig, configValue: UserConfig = {}, viteConfig: ViteUserConfig = {}, vitestOptions: VitestOptions = {}) {
const vitest = await createVitest('test', { ...cliOptions, watch: false }, { ...viteConfig, test: configValue as any }, vitestOptions)
@@ -308,6 +308,31 @@ test('can enable browser-cli options for multi-project workspace', async () => {
expect(projects[1].config.browser.headless).toBe(true)
})
+test('show a warning if host is exposed', async () => {
+ const { stderr } = await runVitest({
+ config: false,
+ root: './fixtures/fixture-no-async',
+ reporters: [
+ {
+ onInit() {
+ throw new Error('stop')
+ },
+ },
+ ],
+ }, [], 'test', {
+ test: {
+ browser: {
+ api: {
+ host: 'custom-host',
+ },
+ },
+ },
+ })
+ expect(stderr).toContain(
+ 'API server is exposed to network, disabling write and exec operations by default for security reasons. This can cause some APIs to not work as expected. Set `browser.api.allowExec` manually to hide this warning. See https://vitest.dev/config/browser/api for more details.',
+ )
+})
+
function getCliConfig(options: UserConfig, cli: string[], fs: TestFsStructure = {}) {
const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`)
useFS(root, {
diff --git a/test/config/test/override.test.ts b/test/config/test/override.test.ts
index 19691dc00..e9c7c92cc 100644
--- a/test/config/test/override.test.ts
+++ b/test/config/test/override.test.ts
@@ -249,6 +249,8 @@ describe('correctly defines api flag', () => {
})
expect(c.server.config.server.middlewareMode).toBe(true)
expect(c.config.api).toEqual({
+ allowExec: true,
+ allowWrite: true,
middlewareMode: true,
token: expect.any(String),
})
@@ -263,10 +265,67 @@ describe('correctly defines api flag', () => {
})
expect(c.server.config.server.port).toBe(4321)
expect(c.config.api).toEqual({
+ allowExec: true,
+ allowWrite: true,
port: 4321,
token: expect.any(String),
})
})
+
+ it('allowWrite and allowExec default to true when not exposed to network', async () => {
+ const c = await config({ api: { port: 5555 } }, {})
+ expect(c.api.allowWrite).toBe(true)
+ expect(c.api.allowExec).toBe(true)
+ })
+
+ it('allowWrite and allowExec default to true for localhost', async () => {
+ const c = await config({ api: { port: 5555, host: 'localhost' } }, {})
+ expect(c.api.allowWrite).toBe(true)
+ expect(c.api.allowExec).toBe(true)
+ })
+
+ it('allowWrite and allowExec default to true for 127.0.0.1', async () => {
+ const c = await config({ api: { port: 5555, host: '127.0.0.1' } }, {})
+ expect(c.api.allowWrite).toBe(true)
+ expect(c.api.allowExec).toBe(true)
+ })
+
+ it('allowWrite and allowExec default to false when exposed to network', async () => {
+ const c = await config({ api: { port: 5555, host: '0.0.0.0' } }, {})
+ expect(c.api.allowWrite).toBe(false)
+ expect(c.api.allowExec).toBe(false)
+ })
+
+ it('allowWrite and allowExec can be explicitly overridden when exposed to network', async () => {
+ const c = await config({ api: { port: 5555, host: '0.0.0.0', allowWrite: true, allowExec: true } }, {})
+ expect(c.api.allowWrite).toBe(true)
+ expect(c.api.allowExec).toBe(true)
+ })
+
+ it('allowWrite and allowExec can be explicitly disabled', async () => {
+ const c = await config({ api: { port: 5555, allowWrite: false, allowExec: false } }, {})
+ expect(c.api.allowWrite).toBe(false)
+ expect(c.api.allowExec).toBe(false)
+ })
+
+ it('browser.api inherits allowWrite and allowExec from api', async () => {
+ const c = await config({ api: { port: 5555, allowWrite: false, allowExec: false } }, {})
+ expect(c.browser.api.allowWrite).toBe(false)
+ expect(c.browser.api.allowExec).toBe(false)
+ })
+
+ it('browser.api can override inherited allowWrite and allowExec', async () => {
+ const c = await config({
+ api: { port: 5555, allowWrite: false, allowExec: false },
+ browser: { api: { allowWrite: true, allowExec: true } },
+ }, {
+ browser: {},
+ })
+ expect(c.api.allowWrite).toBe(false)
+ expect(c.api.allowExec).toBe(false)
+ expect(c.browser.api.allowWrite).toBe(true)
+ expect(c.browser.api.allowExec).toBe(true)
+ })
})
describe.each([
diff --git a/test/core/vite.config.ts b/test/core/vite.config.ts
index 9d5b2fd61..180ad82d8 100644
--- a/test/core/vite.config.ts
+++ b/test/core/vite.config.ts
@@ -55,6 +55,8 @@ export default defineConfig({
test: {
api: {
port: 3023,
+ allowExec: false,
+ allowWrite: false,
},
name: 'core',
includeSource: [