Sitelet https://github.com/vitest-dev/vitest/commit/fe5a11d3c
Skip to content

Commit fe5a11d

Browse files
authored
fix(mocker): restrict redirect mocks to the fs allowlist [backport to v4] (#10974)
1 parent 9851dbc commit fe5a11d

6 files changed

Lines changed: 109 additions & 2 deletions

File tree

packages/browser/src/node/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ export const createBrowserServer: BrowserServerFactory = async (options) => {
9797
options.metaEnvReplacer(),
9898
...(project.options?.plugins || []),
9999
BrowserPlugin(server),
100-
interceptorPlugin({ registry: mockerRegistry }),
100+
// browser mocks register through the authenticated RPC (`setupBrowserRpc`),
101+
// so the raw dev-server socket must not accept mock registration
102+
interceptorPlugin({ registry: mockerRegistry, registerWebSocketEvents: false }),
101103
options.coveragePlugin(),
102104
],
103105
})

packages/browser/src/node/rpc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
386386
if (module.type === 'redirect') {
387387
const redirectUrl = new URL(module.redirect)
388388
module.redirect = join(vite.config.root, redirectUrl.pathname)
389+
checkFileAccess(module.redirect)
389390
}
390391
defaultMockerRegistry.register(module)
391392
}

packages/mocker/src/node/interceptorPlugin.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Plugin } from 'vite'
22
import type { MockedModuleSerialized } from '../registry'
33
import { readFile } from 'node:fs/promises'
44
import { join } from 'node:path/posix'
5+
import { isFileLoadingAllowed } from 'vite'
56
import { ManualMockedModule, MockerRegistry } from '../registry'
67
import { cleanUrl, createManualModuleSource } from '../utils'
78
import { automockModule } from './automock'
@@ -12,6 +13,13 @@ export interface InterceptorPluginOptions {
1213
*/
1314
globalThisAccessor?: string
1415
registry?: MockerRegistry
16+
/**
17+
* Register the `vitest:interceptor:*` WebSocket events in `configureServer`.
18+
* Disable this when mocks are registered through another authenticated
19+
* channel and the raw dev-server socket should not accept them.
20+
* @default true
21+
*/
22+
registerWebSocketEvents?: boolean
1523
}
1624

1725
export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugin {
@@ -56,6 +64,9 @@ export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugi
5664
},
5765
},
5866
configureServer(server) {
67+
if (options.registerWebSocketEvents === false) {
68+
return
69+
}
5970
server.ws.on('vitest:interceptor:register', (event: MockedModuleSerialized) => {
6071
if (event.type === 'manual') {
6172
const module = ManualMockedModule.fromJSON(event, async () => {
@@ -67,7 +78,14 @@ export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugi
6778
else {
6879
if (event.type === 'redirect') {
6980
const redirectUrl = new URL(event.redirect)
70-
event.redirect = join(server.config.root, redirectUrl.pathname)
81+
const redirect = join(server.config.root, redirectUrl.pathname)
82+
// the redirect is served through the `load` hook below, so it must
83+
// stay inside the file-serving allowlist and never escape the root
84+
if (!isFileLoadingAllowed(server.config, redirect)) {
85+
server.ws.send('vitest:interceptor:register:result')
86+
return
87+
}
88+
event.redirect = redirect
7189
}
7290
registry.register(event)
7391
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const marker = 'in-root-redirect-ok'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
should-never-be-served-as-a-module
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { interceptorPlugin } from '@vitest/mocker/node'
3+
import { createServer } from 'vite'
4+
import { expect, it, onTestFinished } from 'vitest'
5+
import { WebSocket } from 'ws'
6+
7+
const root = fileURLToPath(
8+
new URL('../fixtures/mocker/redirect-security/root', import.meta.url),
9+
)
10+
11+
async function createMockerServer() {
12+
const server = await createServer({
13+
root,
14+
configFile: false,
15+
logLevel: 'silent',
16+
server: {
17+
fs: { allow: [root] },
18+
},
19+
plugins: [
20+
{
21+
name: 'test:virtual-mock',
22+
enforce: 'pre',
23+
resolveId(id) {
24+
if (id === '/mock') {
25+
return id
26+
}
27+
},
28+
},
29+
interceptorPlugin(),
30+
],
31+
})
32+
await server.listen()
33+
onTestFinished(() => server.close())
34+
const port = new URL(server.resolvedUrls!.local[0]).port
35+
return { server, port }
36+
}
37+
38+
function registerRedirect(port: string, redirect: string) {
39+
return new Promise<void>((resolve, reject) => {
40+
const ws = new WebSocket(`ws://localhost:${port}`, 'vite-hmr')
41+
const timeout = setTimeout(() => {
42+
ws.close()
43+
reject(new Error('timed out waiting for the register result'))
44+
}, 5000)
45+
ws.on('message', (raw) => {
46+
let message: any
47+
try {
48+
message = JSON.parse(raw.toString())
49+
}
50+
catch {
51+
return
52+
}
53+
if (message.type === 'custom' && message.event === 'vitest:interceptor:register:result') {
54+
clearTimeout(timeout)
55+
ws.close()
56+
resolve()
57+
}
58+
})
59+
ws.on('open', () => {
60+
ws.send(JSON.stringify({
61+
type: 'custom',
62+
event: 'vitest:interceptor:register',
63+
data: { type: 'redirect', raw: '', id: '/mock', url: '/mock', redirect },
64+
}))
65+
})
66+
ws.on('error', reject)
67+
})
68+
}
69+
70+
it('rejects a redirect mock whose target escapes the project root', async () => {
71+
const { server, port } = await createMockerServer()
72+
// an opaque URL scheme keeps the `..` segments, so join(root, pathname)
73+
// resolves outside the root; the mock must not be registered
74+
await registerRedirect(port, 'traversal:../secret.txt')
75+
const result = await server.transformRequest('/mock').catch(() => null)
76+
expect(result).toBe(null)
77+
})
78+
79+
it('serves a redirect mock whose target stays inside the project root', async () => {
80+
const { server, port } = await createMockerServer()
81+
await registerRedirect(port, 'traversal:inroot.js')
82+
const result = await server.transformRequest('/mock').catch(() => null)
83+
expect(result?.code).toContain('in-root-redirect-ok')
84+
})

0 commit comments

Comments
 (0)