| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -97,7 +97,9 @@ export const createBrowserServer: BrowserServerFactory = async (options) => { | |||
| 97 | 97 | options.metaEnvReplacer(), | |
| 98 | 98 | ...(project.options?.plugins || []), | |
| 99 | 99 | 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 }), | ||
| 101 | 103 | options.coveragePlugin(), | |
| 102 | 104 | ], | |
| 103 | 105 | }) | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -386,6 +386,7 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke | |||
| 386 | 386 | if (module.type === 'redirect') { | |
| 387 | 387 | const redirectUrl = new URL(module.redirect) | |
| 388 | 388 | module.redirect = join(vite.config.root, redirectUrl.pathname) | |
| 389 | + checkFileAccess(module.redirect) | ||
| 389 | 390 | } | |
| 390 | 391 | defaultMockerRegistry.register(module) | |
| 391 | 392 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,6 +2,7 @@ import type { Plugin } from 'vite' | |||
| 2 | 2 | import type { MockedModuleSerialized } from '../registry' | |
| 3 | 3 | import { readFile } from 'node:fs/promises' | |
| 4 | 4 | import { join } from 'node:path/posix' | |
| 5 | + import { isFileLoadingAllowed } from 'vite' | ||
| 5 | 6 | import { ManualMockedModule, MockerRegistry } from '../registry' | |
| 6 | 7 | import { cleanUrl, createManualModuleSource } from '../utils' | |
| 7 | 8 | import { automockModule } from './automock' | |
@@ -12,6 +13,13 @@ export interface InterceptorPluginOptions { | |||
| 12 | 13 | */ | |
| 13 | 14 | globalThisAccessor?: string | |
| 14 | 15 | 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 | ||
| 15 | 23 | } | |
| 16 | 24 | ||
| 17 | 25 | export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugin { | |
@@ -56,6 +64,9 @@ export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugi | |||
| 56 | 64 | }, | |
| 57 | 65 | }, | |
| 58 | 66 | configureServer(server) { | |
| 67 | + if (options.registerWebSocketEvents === false) { | ||
| 68 | + return | ||
| 69 | + } | ||
| 59 | 70 | server.ws.on('vitest:interceptor:register', (event: MockedModuleSerialized) => { | |
| 60 | 71 | if (event.type === 'manual') { | |
| 61 | 72 | const module = ManualMockedModule.fromJSON(event, async () => { | |
@@ -67,7 +78,14 @@ export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugi | |||
| 67 | 78 | else { | |
| 68 | 79 | if (event.type === 'redirect') { | |
| 69 | 80 | 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 | ||
| 71 | 89 | } | |
| 72 | 90 | registry.register(event) | |
| 73 | 91 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1 @@ | |||
| 1 | + export const marker = 'in-root-redirect-ok' | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1 @@ | |||
| 1 | + should-never-be-served-as-a-module | ||
| Original file line number | Diff line number | Diff 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 | + }) | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments