fix: restrict access to file system via API (#3956)

This commit is contained in:
Vladimir 2023-08-15 17:05:50 +02:00 committed by GitHub
parent 91fe4853a5
commit bcb41e514f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 143 additions and 49 deletions

View File

@ -11,11 +11,11 @@ export class BrowserSnapshotEnvironment implements SnapshotEnvironment {
}
readSnapshotFile(filepath: string): Promise<string | null> {
return rpc().readFile(filepath)
return rpc().readSnapshotFile(filepath)
}
saveSnapshotFile(filepath: string, snapshot: string): Promise<void> {
return rpc().writeFile(filepath, snapshot, true)
return rpc().saveSnapshotFile(filepath, snapshot)
}
resolvePath(filepath: string): Promise<string> {
@ -27,10 +27,6 @@ export class BrowserSnapshotEnvironment implements SnapshotEnvironment {
}
removeSnapshotFile(filepath: string): Promise<void> {
return rpc().removeFile(filepath)
}
async prepareDirectory(dirPath: string): Promise<void> {
await rpc().createDirectory(dirPath)
return rpc().removeSnapshotFile(filepath)
}
}

View File

@ -3,6 +3,7 @@ import type { SnapshotResult, SnapshotStateOptions, SnapshotSummary } from './ty
export class SnapshotManager {
summary: SnapshotSummary = undefined!
resolvedPaths = new Set<string>()
extension = '.snap'
constructor(public options: Omit<SnapshotStateOptions, 'snapshotEnvironment'>) {
@ -26,7 +27,9 @@ export class SnapshotManager {
)
})
return resolver(testPath, this.extension)
const path = resolver(testPath, this.extension)
this.resolvedPaths.add(path)
return path
}
resolveRawPath(testPath: string, rawPath: string) {

View File

@ -5,7 +5,6 @@
* LICENSE file in the root directory of this source tree.
*/
import { dirname, join } from 'pathe'
import naturalCompare from 'natural-compare'
import type { OptionsReceived as PrettyFormatOptions } from 'pretty-format'
import {
@ -128,13 +127,6 @@ function printBacktickString(str: string): string {
return `\`${escapeBacktickString(str)}\``
}
export async function ensureDirectoryExists(environment: SnapshotEnvironment, filePath: string) {
try {
await environment.prepareDirectory(join(dirname(filePath)))
}
catch { }
}
export function normalizeNewlines(string: string) {
return string.replace(/\r\n|\r/g, '\n')
}
@ -157,7 +149,6 @@ export async function saveSnapshotFile(
if (skipWriting)
return
await ensureDirectoryExists(environment, snapshotPath)
await environment.saveSnapshotFile(
snapshotPath,
content,
@ -175,7 +166,6 @@ export async function saveSnapshotFileRaw(
if (skipWriting)
return
await ensureDirectoryExists(environment, snapshotPath)
await environment.saveSnapshotFile(
snapshotPath,
content,

View File

@ -3,7 +3,6 @@ export interface SnapshotEnvironment {
getHeader(): string
resolvePath(filepath: string): Promise<string>
resolveRawPath(testPath: string, rawPath: string): Promise<string>
prepareDirectory(dirPath: string): Promise<void>
saveSnapshotFile(filepath: string, snapshot: string): Promise<void>
readSnapshotFile(filepath: string): Promise<string | null>
removeSnapshotFile(filepath: string): Promise<void>

View File

@ -24,7 +24,7 @@ watch(() => props.file,
draft.value = false
return
}
code.value = await client.rpc.readFile(props.file.filepath) || ''
code.value = await client.rpc.readTestFile(props.file.filepath) || ''
serverCode.value = code.value
draft.value = false
},
@ -116,7 +116,7 @@ watch([cm, failed], ([cmValue]) => {
async function onSave(content: string) {
hasBeenEdited.value = true
await client.rpc.writeFile(props.file!.filepath, content)
await client.rpc.saveTestFile(props.file!.filepath, content)
serverCode.value = content
draft.value = false
}

View File

@ -46,21 +46,26 @@ export function createStaticClient(): VitestClient {
return {
code: id,
source: '',
map: null,
}
},
readFile: async (id) => {
return Promise.resolve(id)
},
onDone: noop,
onCollected: asyncNoop,
onTaskUpdate: noop,
writeFile: asyncNoop,
rerun: asyncNoop,
updateSnapshot: asyncNoop,
removeFile: asyncNoop,
createDirectory: asyncNoop,
resolveSnapshotPath: asyncNoop,
snapshotSaved: asyncNoop,
onAfterSuiteRun: asyncNoop,
onCancel: asyncNoop,
getCountOfFailedTests: () => 0,
sendLog: asyncNoop,
resolveSnapshotRawPath: asyncNoop,
readSnapshotFile: asyncNoop,
saveSnapshotFile: asyncNoop,
readTestFile: asyncNoop,
removeSnapshotFile: asyncNoop,
} as WebSocketHandlers
ctx.rpc = rpc as any as BirpcReturn<WebSocketHandlers>

View File

@ -127,6 +127,9 @@ export function parseSingleV8Stack(raw: string): ParsedStack | null {
// normalize Windows path (\ -> /)
file = resolve(file)
if (method)
method = method.replace(/__vite_ssr_import_\d+__\./g, '')
return {
method,
file,

View File

@ -69,25 +69,36 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, server?: Vit
resolveSnapshotRawPath(testPath, rawPath) {
return ctx.snapshot.resolveRawPath(testPath, rawPath)
},
removeFile(id) {
return fs.unlink(id)
async readSnapshotFile(snapshotPath) {
if (!ctx.snapshot.resolvedPaths.has(snapshotPath) || !existsSync(snapshotPath))
return null
return fs.readFile(snapshotPath, 'utf-8')
},
createDirectory(id) {
return fs.mkdir(id, { recursive: true })
},
async readFile(id) {
if (!existsSync(id))
async readTestFile(id) {
if (!ctx.state.filesMap.has(id) || !existsSync(id))
return null
return fs.readFile(id, 'utf-8')
},
async saveTestFile(id, content) {
// can save only already existing test file
if (!ctx.state.filesMap.has(id) || !existsSync(id))
return
return fs.writeFile(id, content, 'utf-8')
},
async saveSnapshotFile(id, content) {
if (!ctx.snapshot.resolvedPaths.has(id))
return
await fs.mkdir(dirname(id), { recursive: true })
return fs.writeFile(id, content, 'utf-8')
},
async removeSnapshotFile(id) {
if (!ctx.snapshot.resolvedPaths.has(id) || !existsSync(id))
return
return fs.unlink(id)
},
snapshotSaved(snapshot) {
ctx.snapshot.add(snapshot)
},
async writeFile(id, content, ensureDir) {
if (ensureDir)
await fs.mkdir(dirname(id), { recursive: true })
return await fs.writeFile(id, content, 'utf-8')
},
async rerun(files) {
await ctx.rerunFiles(files)
},

View File

@ -21,10 +21,11 @@ export interface WebSocketHandlers {
resolveSnapshotRawPath(testPath: string, rawPath: string): string
getModuleGraph(id: string): Promise<ModuleGraphData>
getTransformResult(id: string): Promise<TransformResultWithSource | undefined>
readFile(id: string): Promise<string | null>
writeFile(id: string, content: string, ensureDir?: boolean): Promise<void>
removeFile(id: string): Promise<void>
createDirectory(id: string): Promise<string | undefined>
readSnapshotFile(id: string): Promise<string | null>
readTestFile(id: string): Promise<string | null>
saveTestFile(id: string, content: string): Promise<void>
saveSnapshotFile(id: string, content: string): Promise<void>
removeSnapshotFile(id: string): Promise<void>
snapshotSaved(snapshot: SnapshotResult): void
rerun(files: string[]): Promise<void>
updateSnapshot(file?: File): Promise<void>

View File

@ -8,6 +8,7 @@ import { resolveApiServerConfig } from '../../node/config'
import { CoverageTransform } from '../../node/plugins/coverageTransform'
import type { WorkspaceProject } from '../../node/workspace'
import { MocksPlugin } from '../../node/plugins/mocks'
import { resolveFsAllow } from '../../node/plugins/utils'
export async function createBrowserServer(project: WorkspaceProject, options: UserConfig) {
const root = project.config.root
@ -44,7 +45,13 @@ export async function createBrowserServer(project: WorkspaceProject, options: Us
config.server = server
config.server.fs ??= {}
config.server.fs.strict = false
config.server.fs.allow = config.server.fs.allow || []
config.server.fs.allow.push(
...resolveFsAllow(
project.ctx.config.root,
project.ctx.server.config.configFile,
),
)
return {
resolve: {

View File

@ -17,6 +17,8 @@ export async function createVitest(mode: VitestRunMode, options: UserConfig, vit
? resolve(root, options.config)
: await findUp(configFiles, { cwd: root } as any)
options.config = configPath
const config: ViteInlineConfig = {
logLevel: 'error',
configFile: configPath,

View File

@ -12,7 +12,7 @@ import { GlobalSetupPlugin } from './globalSetup'
import { CSSEnablerPlugin } from './cssEnabler'
import { CoverageTransform } from './coverageTransform'
import { MocksPlugin } from './mocks'
import { deleteDefineConfig, hijackVitePluginInject, resolveOptimizerConfig } from './utils'
import { deleteDefineConfig, hijackVitePluginInject, resolveFsAllow, resolveOptimizerConfig } from './utils'
import { VitestResolver } from './vitestResolver'
export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('test')): Promise<VitePlugin[]> {
@ -87,6 +87,9 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t
open,
hmr: false,
preTransformRequests: false,
fs: {
allow: resolveFsAllow(getRoot(), testConfig.config),
},
},
}

View File

@ -1,6 +1,7 @@
import { builtinModules } from 'node:module'
import { version as viteVersion } from 'vite'
import { searchForWorkspaceRoot, version as viteVersion } from 'vite'
import type { DepOptimizationOptions, ResolvedConfig, UserConfig as ViteConfig } from 'vite'
import { dirname } from 'pathe'
import type { DepsOptimizationOptions, InlineConfig } from '../../types'
export function resolveOptimizerConfig(_testOptions: DepsOptimizationOptions | undefined, viteOptions: DepOptimizationOptions | undefined, testConfig: InlineConfig) {
@ -84,3 +85,9 @@ export function hijackVitePluginInject(viteConfig: ResolvedConfig) {
}
}
}
export function resolveFsAllow(projectRoot: string, rootConfigFile: string | false | undefined) {
if (!rootConfigFile)
return [searchForWorkspaceRoot(projectRoot)]
return [dirname(rootConfigFile), searchForWorkspaceRoot(projectRoot)]
}

View File

@ -10,7 +10,7 @@ import { CSSEnablerPlugin } from './cssEnabler'
import { SsrReplacerPlugin } from './ssrReplacer'
import { GlobalSetupPlugin } from './globalSetup'
import { MocksPlugin } from './mocks'
import { deleteDefineConfig, hijackVitePluginInject, resolveOptimizerConfig } from './utils'
import { deleteDefineConfig, hijackVitePluginInject, resolveFsAllow, resolveOptimizerConfig } from './utils'
import { VitestResolver } from './vitestResolver'
interface WorkspaceOptions extends UserWorkspaceConfig {
@ -69,6 +69,12 @@ export function WorkspaceVitestPlugin(project: WorkspaceProject, options: Worksp
open: false,
hmr: false,
preTransformRequests: false,
fs: {
allow: resolveFsAllow(
project.ctx.config.root,
project.ctx.server.config.configFile,
),
},
},
test: {
env,

15
pnpm-lock.yaml generated
View File

@ -1840,6 +1840,15 @@ importers:
specifier: workspace:*
version: link:../../packages/vitest
test/restricted:
devDependencies:
jsdom:
specifier: ^22.1.0
version: 22.1.0
vitest:
specifier: workspace:*
version: link:../../packages/vitest
test/run:
devDependencies:
vite:
@ -6771,7 +6780,7 @@ packages:
engines: {node: '>=14'}
hasBin: true
dependencies:
'@types/node': 20.5.0
'@types/node': 18.16.19
playwright-core: 1.28.0
dev: true
@ -8928,7 +8937,7 @@ packages:
resolution: {integrity: sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA==}
dependencies:
'@types/jsonfile': 6.1.1
'@types/node': 18.16.19
'@types/node': 20.5.0
dev: true
/@types/fs-extra@9.0.13:
@ -9318,7 +9327,7 @@ packages:
/@types/ws@8.5.5:
resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==}
dependencies:
'@types/node': 18.16.19
'@types/node': 20.5.0
dev: true
/@types/yargs-parser@21.0.0:

View File

@ -0,0 +1,12 @@
{
"name": "@vitest/test-restricted",
"private": true,
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage"
},
"devDependencies": {
"jsdom": "^22.1.0",
"vitest": "workspace:*"
}
}

View File

@ -0,0 +1,3 @@
export function multiply(a, b) {
return a * b
}

View File

@ -0,0 +1,7 @@
import { expect, it } from 'vitest'
import { multiply } from '../src/math'
it('2 x 2 = 4', () => {
expect(multiply(2, 2)).toBe(4)
expect(multiply(2, 2)).toBe(Math.sqrt(16))
})

View File

@ -0,0 +1,29 @@
import { resolve } from 'pathe'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
{
// simulates restrictive FS
name: 'restrict-fs',
config() {
return {
server: {
fs: {
allow: [
resolve(__dirname, 'src'),
],
},
},
}
},
},
],
test: {
environment: 'jsdom',
include: ['tests/**/*.spec.{js,ts}'],
setupFiles: [
'./vitest.setup.js',
],
},
})

View File

@ -0,0 +1 @@
globalThis.SOME_TEST_VARIABLE = '3'