[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix(browser): check fs access in builtin commands [backport to v4] (#10680)

Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com>
Co-authored-by: OpenCode (claude-opus-4-8) <noreply@opencode.ai>

authored by

Hiroshi Ogawa
Hiroshi Ogawa
OpenCode (claude-opus-4-8)
and committed by
GitHub
(Jul 1, 2026, 8:07 AM +0200) 5c18dd26 bae52b51

+218 -34
+3
test/test-utils/index.ts
··· 577 577 578 578 function mapError(e: { message: string; diff?: string; stacks?: { file: string; line: number; column: number; method: string }[] }) { 579 579 let message = e.message 580 + if (root) { 581 + message = replaceRoot(message, root) 582 + } 580 583 if (options?.diff && e.diff) { 581 584 message = [message, stripVTControlCharacters(e.diff)].join('\n') 582 585 }
+48
test/browser/specs/errors.test.ts
··· 251 251 } 252 252 `) 253 253 }) 254 + 255 + test('upload is blocked for files denied by server.fs.deny', async () => { 256 + const result = await runBrowserTests({ 257 + root: './fixtures/command-permissions-upload-denied', 258 + project: [instances[0].browser], 259 + }) 260 + expect(result.errorTree()).toMatchInlineSnapshot(` 261 + { 262 + "upload-denied.test.ts": { 263 + "upload denied path": [ 264 + "Access denied to "<root>/my-secret.txt". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.", 265 + ], 266 + }, 267 + } 268 + `) 269 + }) 270 + 271 + test('takeScreenshot is blocked for files denied by server.fs.deny', async () => { 272 + const result = await runBrowserTests({ 273 + root: './fixtures/command-permissions-screenshot-denied', 274 + project: [instances[0].browser], 275 + }) 276 + expect(result.errorTree()).toMatchInlineSnapshot(` 277 + { 278 + "screenshot-denied.test.ts": { 279 + "screenshot denied path": [ 280 + "Access denied to "<root>/my-secret.png". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.", 281 + ], 282 + }, 283 + } 284 + `) 285 + }) 286 + 287 + test('takeScreenshot is blocked when write is disabled', async () => { 288 + const result = await runBrowserTests({ 289 + root: './fixtures/command-permissions-screenshot-no-write', 290 + project: [instances[0].browser], 291 + }) 292 + expect(result.errorTree()).toMatchInlineSnapshot(` 293 + { 294 + "screenshot-write.test.ts": { 295 + "screenshot blocked": [ 296 + "Cannot modify file "<root>/out.png". File writing is disabled because the server is exposed to the internet, see https://vitest.dev/config/browser/api.", 297 + ], 298 + }, 299 + } 300 + `) 301 + })
+4 -1
packages/browser-playwright/src/commands/screenshot.ts
··· 1 1 import type { ScreenshotOptions } from 'vitest/browser' 2 2 import type { BrowserCommandContext } from 'vitest/node' 3 3 import { mkdir } from 'node:fs/promises' 4 - import { resolveScreenshotPath } from '@vitest/browser' 4 + import { assertBrowserApiWrite, assertBrowserFileAccess, resolveScreenshotPath } from '@vitest/browser' 5 5 import { dirname, normalize } from 'pathe' 6 6 import { getDescribedLocator } from './utils' 7 7 ··· 38 38 39 39 if (options.save) { 40 40 savePath = normalize(path) 41 + 42 + assertBrowserApiWrite(context.project, savePath) 43 + assertBrowserFileAccess(context.project, savePath) 41 44 42 45 await mkdir(dirname(savePath), { recursive: true }) 43 46 }
+9
packages/browser-playwright/src/commands/trace.ts
··· 2 2 import type { BrowserCommand, BrowserCommandContext, BrowserProvider } from 'vitest/node' 3 3 import type { PlaywrightBrowserProvider } from '../playwright' 4 4 import { unlink } from 'node:fs/promises' 5 + import { assertBrowserApiWrite, assertBrowserFileAccess } from '@vitest/browser' 5 6 import { basename, dirname, relative, resolve } from 'pathe' 6 7 import { getDescribedLocator } from './utils' 7 8 ··· 51 52 ) => { 52 53 if (isPlaywrightProvider(context.provider)) { 53 54 const path = resolveTracesPath(context, name) 55 + assertBrowserApiWrite(context.project, path) 56 + assertBrowserFileAccess(context.project, path) 54 57 context.provider.pendingTraces.delete(path) 55 58 await context.context.tracing.stopChunk({ path }) 56 59 return { tracePath: path } ··· 162 165 throw new Error(`stopChunkTrace cannot be called outside of the test file.`) 163 166 } 164 167 if (isPlaywrightProvider(context.provider)) { 168 + for (const trace of traces) { 169 + assertBrowserApiWrite(context.project, trace) 170 + assertBrowserFileAccess(context.project, trace) 171 + } 165 172 return Promise.all( 166 173 traces.map(trace => unlink(trace).catch((err) => { 167 174 if (err.code === 'ENOENT') { ··· 183 190 ) => { 184 191 const vitest = project.vitest 185 192 await Promise.all(traces.map((trace) => { 193 + assertBrowserApiWrite(project, trace) 194 + assertBrowserFileAccess(project, trace) 186 195 const entity = vitest.state.getReportedEntityById(testId) 187 196 const location = entity?.location 188 197 ? {
+4 -1
packages/browser-playwright/src/commands/upload.ts
··· 1 1 import type { UserEventUploadOptions } from 'vitest/browser' 2 2 import type { UserEventCommand } from './utils' 3 + import { assertBrowserFileAccess } from '@vitest/browser' 3 4 import { resolve } from 'pathe' 4 5 import { getDescribedLocator } from './utils' 5 6 ··· 21 22 22 23 const playwrightFiles = files.map((file) => { 23 24 if (typeof file === 'string') { 24 - return resolve(root, file) 25 + const filepath = resolve(root, file) 26 + assertBrowserFileAccess(context.project, filepath) 27 + return filepath 25 28 } 26 29 return { 27 30 name: file.name,
+4 -1
packages/browser-webdriverio/src/commands/screenshot.ts
··· 3 3 import crypto from 'node:crypto' 4 4 import { mkdir, rm } from 'node:fs/promises' 5 5 import { normalize as platformNormalize } from 'node:path' 6 - import { resolveScreenshotPath } from '@vitest/browser' 6 + import { assertBrowserApiWrite, assertBrowserFileAccess, resolveScreenshotPath } from '@vitest/browser' 7 7 import { dirname, normalize, resolve } from 'pathe' 8 8 9 9 interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> { ··· 40 40 41 41 if (options.save) { 42 42 savePath = normalize(path) 43 + 44 + assertBrowserApiWrite(context.project, savePath) 45 + assertBrowserFileAccess(context.project, savePath) 43 46 44 47 await mkdir(dirname(savePath), { recursive: true }) 45 48 }
+2
packages/browser-webdriverio/src/commands/upload.ts
··· 1 1 import type { UserEventUploadOptions } from 'vitest/browser' 2 2 import type { UserEventCommand } from './utils' 3 + import { assertBrowserFileAccess } from '@vitest/browser' 3 4 import { resolve } from 'pathe' 4 5 5 6 export const upload: UserEventCommand<(element: string, files: Array<string | { ··· 28 29 29 30 for (const file of files) { 30 31 const filepath = resolve(root, file as string) 32 + assertBrowserFileAccess(context.project, filepath) 31 33 const remoteFilePath = await context.browser.uploadFile(filepath) 32 34 await element.addValue(remoteFilePath) 33 35 }
+1 -1
packages/browser/src/node/index.ts
··· 18 18 } 19 19 20 20 // export type { ProjectBrowser } from './project' 21 - export { parseKeyDef, resolveScreenshotPath } from './utils' 21 + export { assertBrowserApiWrite, assertBrowserFileAccess, parseKeyDef, resolveScreenshotPath } from './utils' 22 22 23 23 export { asLocator } from 'ivya' 24 24
+14 -1
packages/browser/src/node/rpc.ts
··· 12 12 import { extractSourcemapFromFile } from '@vitest/utils/source-map/node' 13 13 import { createBirpc } from 'birpc' 14 14 import { parse, stringify } from 'flatted' 15 - import { dirname, join } from 'pathe' 15 + import { dirname, join, resolve } from 'pathe' 16 16 import { createDebugger, isFileLoadingAllowed, isValidApiRequest } from 'vitest/node' 17 17 import { WebSocketServer } from 'ws' 18 18 ··· 209 209 vitest.logger.error( 210 210 `[vitest] Cannot record attachments ("${attachments}") because file writing is disabled, removing attachments from artifact "${artifact.type}". See https://vitest.dev/config/browser/api.`, 211 211 ) 212 + } 213 + } 214 + else { 215 + // attachment files are copied into `attachmentsDir`, so confine 216 + // client-supplied paths to Vite's `server.fs` boundary 217 + const attachments = artifact.type === 'internal:annotation' 218 + ? (artifact.annotation.attachment ? [artifact.annotation.attachment] : []) 219 + : (artifact.attachments ?? []) 220 + for (const attachment of attachments) { 221 + const path = attachment.path 222 + if (path && !path.startsWith('http://') && !path.startsWith('https://')) { 223 + checkFileAccess(resolve(project.config.root, path)) 224 + } 212 225 } 213 226 } 214 227
+21
packages/browser/src/node/utils.ts
··· 8 8 import { defaultKeyMap } from '@testing-library/user-event/dist/esm/keyboard/keyMap.js' 9 9 import { parseKeyDef as tlParse } from '@testing-library/user-event/dist/esm/keyboard/parseKeyDef.js' 10 10 import { basename, dirname, relative, resolve } from 'pathe' 11 + import { isFileLoadingAllowed } from 'vitest/node' 11 12 12 13 declare enum DOM_KEY_LOCATION { 13 14 STANDARD = 0, ··· 94 95 95 96 export function slash(path: string): string { 96 97 return path.replace(/\\/g, '/').replace(/\/+/g, '/') 98 + } 99 + 100 + export function assertBrowserFileAccess(project: TestProject, path: string): void { 101 + const normalized = slash(path) 102 + if ( 103 + !isFileLoadingAllowed(project.vite.config, normalized) 104 + && !isFileLoadingAllowed(project.vitest.vite.config, normalized) 105 + ) { 106 + throw new Error( 107 + `Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`, 108 + ) 109 + } 110 + } 111 + 112 + export function assertBrowserApiWrite(project: TestProject, path: string): void { 113 + if (!project.config.browser.api.allowWrite || !project.vitest.config.api.allowWrite) { 114 + throw new Error( 115 + `Cannot modify file "${path}". File writing is disabled because the server is exposed to the internet, see https://vitest.dev/config/browser/api.`, 116 + ) 117 + } 97 118 }
+7
test/browser/fixtures/command-permissions-screenshot-denied/screenshot-denied.test.ts
··· 1 + import { test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + test('screenshot denied path', async () => { 5 + // write is allowed, but the target is denied via `server.fs.deny` 6 + await page.screenshot({ path: 'my-secret.png' }) 7 + })
+21
test/browser/fixtures/command-permissions-screenshot-denied/vitest.config.ts
··· 1 + import { fileURLToPath } from 'node:url' 2 + import { defineConfig } from 'vitest/config' 3 + import { instances, provider } from '../../settings' 4 + 5 + export default defineConfig({ 6 + cacheDir: fileURLToPath(new URL('./node_modules/.vite', import.meta.url)), 7 + server: { 8 + fs: { 9 + deny: ['my-secret.png'], 10 + }, 11 + }, 12 + test: { 13 + browser: { 14 + enabled: true, 15 + provider, 16 + instances, 17 + headless: true, 18 + screenshotFailures: false, 19 + }, 20 + }, 21 + })
+6
test/browser/fixtures/command-permissions-screenshot-no-write/screenshot-write.test.ts
··· 1 + import { test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + test('screenshot blocked', async () => { 5 + await page.screenshot({ path: 'out.png' }) 6 + })
+24
test/browser/fixtures/command-permissions-screenshot-no-write/vitest.config.ts
··· 1 + import { fileURLToPath } from 'node:url' 2 + import { defineConfig } from 'vitest/config' 3 + import { instances, provider } from '../../settings' 4 + 5 + export default defineConfig({ 6 + cacheDir: fileURLToPath(new URL('./node_modules/.vite', import.meta.url)), 7 + test: { 8 + api: { 9 + allowExec: false, 10 + allowWrite: false, 11 + }, 12 + browser: { 13 + enabled: true, 14 + provider, 15 + instances, 16 + headless: true, 17 + screenshotFailures: false, 18 + api: { 19 + allowExec: false, 20 + allowWrite: false, 21 + }, 22 + }, 23 + }, 24 + })
+1
test/browser/fixtures/command-permissions-upload-denied/my-secret.txt
··· 1 + secret content
+10
test/browser/fixtures/command-permissions-upload-denied/upload-denied.test.ts
··· 1 + import { test } from 'vitest' 2 + import { userEvent } from 'vitest/browser' 3 + 4 + test('upload denied path', async () => { 5 + const input = document.createElement('input') 6 + input.type = 'file' 7 + document.body.append(input) 8 + // the file exists in the project, but is denied via `server.fs.deny` 9 + await userEvent.upload(input, 'my-secret.txt') 10 + })
+21
test/browser/fixtures/command-permissions-upload-denied/vitest.config.ts
··· 1 + import { fileURLToPath } from 'node:url' 2 + import { defineConfig } from 'vitest/config' 3 + import { instances, provider } from '../../settings' 4 + 5 + export default defineConfig({ 6 + cacheDir: fileURLToPath(new URL('./node_modules/.vite', import.meta.url)), 7 + server: { 8 + fs: { 9 + deny: ['my-secret.txt'], 10 + }, 11 + }, 12 + test: { 13 + browser: { 14 + enabled: true, 15 + provider, 16 + instances, 17 + headless: true, 18 + screenshotFailures: false, 19 + }, 20 + }, 21 + })
+8 -26
packages/browser/src/node/commands/fs.ts
··· 1 1 import type { BrowserCommands } from 'vitest/browser' 2 - import type { BrowserCommand, TestProject } from 'vitest/node' 2 + import type { BrowserCommand } from 'vitest/node' 3 3 import fs, { promises as fsp } from 'node:fs' 4 4 import { basename, dirname, resolve } from 'node:path' 5 5 import mime from 'mime/lite' 6 - import { isFileLoadingAllowed } from 'vitest/node' 7 - import { slash } from '../utils' 8 - 9 - function assertFileAccess(path: string, project: TestProject) { 10 - if ( 11 - !isFileLoadingAllowed(project.vite.config, path) 12 - && !isFileLoadingAllowed(project.vitest.vite.config, path) 13 - ) { 14 - throw new Error( 15 - `Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`, 16 - ) 17 - } 18 - } 19 - 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 - } 6 + import { assertBrowserApiWrite, assertBrowserFileAccess } from '../utils' 25 7 26 8 export const readFile: BrowserCommand< 27 9 Parameters<BrowserCommands['readFile']> 28 10 > = async ({ project }, path, options = {}) => { 29 11 const filepath = resolve(project.config.root, path) 30 - assertFileAccess(slash(filepath), project) 12 + assertBrowserFileAccess(project, filepath) 31 13 // never return a Buffer 32 14 if (typeof options === 'object' && !options.encoding) { 33 15 options.encoding = 'utf-8' ··· 38 20 export const writeFile: BrowserCommand< 39 21 Parameters<BrowserCommands['writeFile']> 40 22 > = async ({ project }, path, data, options) => { 41 - assertWrite(path, project) 23 + assertBrowserApiWrite(project, path) 42 24 const filepath = resolve(project.config.root, path) 43 - assertFileAccess(slash(filepath), project) 25 + assertBrowserFileAccess(project, filepath) 44 26 const dir = dirname(filepath) 45 27 if (!fs.existsSync(dir)) { 46 28 await fsp.mkdir(dir, { recursive: true }) ··· 51 33 export const removeFile: BrowserCommand< 52 34 Parameters<BrowserCommands['removeFile']> 53 35 > = async ({ project }, path) => { 54 - assertWrite(path, project) 36 + assertBrowserApiWrite(project, path) 55 37 const filepath = resolve(project.config.root, path) 56 - assertFileAccess(slash(filepath), project) 38 + assertBrowserFileAccess(project, filepath) 57 39 await fsp.rm(filepath) 58 40 } 59 41 60 42 export const _fileInfo: BrowserCommand<[path: string, encoding: BufferEncoding]> = async ({ project }, path, encoding) => { 61 43 const filepath = resolve(project.config.root, path) 62 - assertFileAccess(slash(filepath), project) 44 + assertBrowserFileAccess(project, filepath) 63 45 const content = await fsp.readFile(filepath, encoding || 'base64') 64 46 return { 65 47 content,
+10 -3
packages/browser/src/node/commands/screenshotMatcher/index.ts
··· 1 1 import type { SnapshotUpdateState } from 'vitest' 2 2 import type { ScreenshotMatcherOptions } from 'vitest/browser' 3 - import type { BrowserCommand, BrowserCommandContext } from 'vitest/node' 3 + import type { BrowserCommand, BrowserCommandContext, TestProject } from 'vitest/node' 4 4 import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types' 5 5 import type { AnyCodec } from './codecs' 6 6 import type { AnyComparator } from './comparators' ··· 8 8 import type { ResolvedOptions } from './utils' 9 9 import { mkdir, readFile, writeFile } from 'node:fs/promises' 10 10 import { basename, dirname } from 'pathe' 11 + import { assertBrowserApiWrite, assertBrowserFileAccess } from '../../utils' 11 12 import { asyncTimeout, resolveOptions, takeDecodedScreenshot } from './utils' 12 13 13 14 /** Decoded image data with dimensions metadata. */ ··· 108 109 comparatorOptions, 109 110 }) 110 111 111 - await performSideEffects(outcome, codec) 112 + await performSideEffects(outcome, codec, context.project) 112 113 113 114 return buildOutput(outcome, timeout) 114 115 } ··· 230 231 async function performSideEffects( 231 232 outcome: MatchOutcome, 232 233 codec: AnyCodec, 234 + project: TestProject, 233 235 ): Promise<void> { 234 236 switch (outcome.type) { 235 237 case 'missing-reference': ··· 237 239 await writeScreenshot( 238 240 outcome.reference.path, 239 241 await codec.encode(outcome.reference.image, {}), 242 + project, 240 243 ) 241 244 242 245 break ··· 246 249 await writeScreenshot( 247 250 outcome.actual.path, 248 251 await codec.encode(outcome.actual.image, {}), 252 + project, 249 253 ) 250 254 251 255 if (outcome.diff) { 252 256 await writeScreenshot( 253 257 outcome.diff.path, 254 258 await codec.encode(outcome.diff.image, {}), 259 + project, 255 260 ) 256 261 } 257 262 ··· 456 461 } 457 462 458 463 /** Writes encoded images to disk, creating parent directories as needed. */ 459 - async function writeScreenshot(path: string, image: TypedArray) { 464 + async function writeScreenshot(path: string, image: TypedArray, project: TestProject) { 460 465 try { 466 + assertBrowserApiWrite(project, path) 467 + assertBrowserFileAccess(project, path) 461 468 await mkdir(dirname(path), { recursive: true }) 462 469 await writeFile(path, image) 463 470 }