Skip to content

Commit 191ef9e

Browse files
hi-ogawaAriPerkkio
andauthored
fix: validate websocket request (#7317)
Co-authored-by: Ari Perkkiö <[email protected]>
1 parent c82387d commit 191ef9e

File tree

16 files changed

+103
-6
lines changed

16 files changed

+103
-6
lines changed

packages/browser/src/client/client.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const RPC_ID
1515
const METHOD = getBrowserState().method
1616
export const ENTRY_URL = `${
1717
location.protocol === 'https:' ? 'wss:' : 'ws:'
18-
}//${HOST}/__vitest_browser_api__?type=${PAGE_TYPE}&rpcId=${RPC_ID}&sessionId=${getBrowserState().sessionId}&projectName=${getBrowserState().config.name || ''}&method=${METHOD}`
18+
}//${HOST}/__vitest_browser_api__?type=${PAGE_TYPE}&rpcId=${RPC_ID}&sessionId=${getBrowserState().sessionId}&projectName=${getBrowserState().config.name || ''}&method=${METHOD}&token=${(window as any).VITEST_API_TOKEN}`
1919

2020
let setCancel = (_: CancelReason) => {}
2121
export const onCancel = new Promise<CancelReason>((resolve) => {

packages/browser/src/client/public/esm-client-injector.js

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
method: { __VITEST_METHOD__ },
3131
providedContext: { __VITEST_PROVIDED_CONTEXT__ },
3232
};
33+
window.VITEST_API_TOKEN = { __VITEST_API_TOKEN__ };
3334

3435
const config = __vitest_browser_runner__.config;
3536

packages/browser/src/node/rpc.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { ServerMockResolver } from '@vitest/mocker/node'
1010
import { createBirpc } from 'birpc'
1111
import { parse, stringify } from 'flatted'
1212
import { dirname } from 'pathe'
13-
import { createDebugger, isFileServingAllowed } from 'vitest/node'
13+
import { createDebugger, isFileServingAllowed, isValidApiRequest } from 'vitest/node'
1414
import { WebSocketServer } from 'ws'
1515

1616
const debug = createDebugger('vitest:browser:api')
@@ -33,6 +33,11 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject) {
3333
return
3434
}
3535

36+
if (!isValidApiRequest(vitest.config, request)) {
37+
socket.destroy()
38+
return
39+
}
40+
3641
const type = searchParams.get('type')
3742
const rpcId = searchParams.get('rpcId')
3843
const sessionId = searchParams.get('sessionId')

packages/browser/src/node/serverOrchestrator.ts

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export async function resolveOrchestrator(
4242
__VITEST_SESSION_ID__: JSON.stringify(sessionId),
4343
__VITEST_TESTER_ID__: '"none"',
4444
__VITEST_PROVIDED_CONTEXT__: '{}',
45+
__VITEST_API_TOKEN__: JSON.stringify(globalServer.vitest.config.api.token),
4546
})
4647

4748
// disable CSP for the orchestrator as we are the ones controlling it

packages/browser/src/node/serverTester.ts

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export async function resolveTester(
6868
__VITEST_SESSION_ID__: JSON.stringify(sessionId),
6969
__VITEST_TESTER_ID__: JSON.stringify(crypto.randomUUID()),
7070
__VITEST_PROVIDED_CONTEXT__: JSON.stringify(stringify(project.getProvidedContext())),
71+
__VITEST_API_TOKEN__: JSON.stringify(globalServer.vitest.config.api.token),
7172
})
7273

7374
const testerHtml = typeof browserProject.testerHtml === 'string'

packages/ui/client/constants.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ export const PORT = import.meta.hot && !browserState ? '51204' : location.port
44
export const HOST = [location.hostname, PORT].filter(Boolean).join(':')
55
export const ENTRY_URL = `${
66
location.protocol === 'https:' ? 'wss:' : 'ws:'
7-
}//${HOST}/__vitest_api__`
7+
}//${HOST}/__vitest_api__?token=${(window as any).VITEST_API_TOKEN}`
88
export const isReport = !!window.METADATA_PATH
99
export const BASE_PATH = isReport ? import.meta.env.BASE_URL : __BASE_PATH__

packages/ui/node/index.ts

+23
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Plugin } from 'vite'
22
import type { Vitest } from 'vitest/node'
3+
import fs from 'node:fs'
34
import { fileURLToPath } from 'node:url'
45
import { toArray } from '@vitest/utils'
56
import { basename, resolve } from 'pathe'
@@ -52,6 +53,28 @@ export default (ctx: Vitest): Plugin => {
5253
}
5354

5455
const clientDist = resolve(fileURLToPath(import.meta.url), '../client')
56+
const clientIndexHtml = fs.readFileSync(resolve(clientDist, 'index.html'), 'utf-8')
57+
58+
// serve index.html with api token
59+
// eslint-disable-next-line prefer-arrow-callback
60+
server.middlewares.use(function vitestUiHtmlMiddleware(req, res, next) {
61+
if (req.url) {
62+
const url = new URL(req.url, 'http://localhost')
63+
if (url.pathname === base) {
64+
const html = clientIndexHtml.replace(
65+
'<!-- !LOAD_METADATA! -->',
66+
`<script>window.VITEST_API_TOKEN = ${JSON.stringify(ctx.config.api.token)}</script>`,
67+
)
68+
res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate')
69+
res.setHeader('Content-Type', 'text/html; charset=utf-8')
70+
res.write(html)
71+
res.end()
72+
return
73+
}
74+
}
75+
next()
76+
})
77+
5578
server.middlewares.use(
5679
base,
5780
sirv(clientDist, {

packages/vitest/rollup.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const external = [
7272
'node:os',
7373
'node:stream',
7474
'node:vm',
75+
'node:http',
7576
'inspector',
7677
'vite-node/source-map',
7778
'vite-node/client',

packages/vitest/src/api/check.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { IncomingMessage } from 'node:http'
2+
import type { ResolvedConfig } from '../node/types/config'
3+
import crypto from 'node:crypto'
4+
5+
export function isValidApiRequest(config: ResolvedConfig, req: IncomingMessage): boolean {
6+
const url = new URL(req.url ?? '', 'http://localhost')
7+
8+
// validate token. token is injected in ui/tester/orchestrator html, which is cross origin proteced.
9+
try {
10+
const token = url.searchParams.get('token')
11+
if (token && crypto.timingSafeEqual(
12+
Buffer.from(token),
13+
Buffer.from(config.api.token),
14+
)) {
15+
return true
16+
}
17+
}
18+
// an error is thrown when the length is incorrect
19+
catch {}
20+
21+
return false
22+
}

packages/vitest/src/api/setup.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { File, TaskResultPack } from '@vitest/runner'
22

3+
import type { IncomingMessage } from 'node:http'
34
import type { ViteDevServer } from 'vite'
45
import type { WebSocket } from 'ws'
56
import type { Vitest } from '../node/core'
@@ -21,6 +22,7 @@ import { API_PATH } from '../constants'
2122
import { getModuleGraph } from '../utils/graph'
2223
import { stringifyReplace } from '../utils/serialization'
2324
import { parseErrorStacktrace } from '../utils/source-map'
25+
import { isValidApiRequest } from './check'
2426

2527
export function setup(ctx: Vitest, _server?: ViteDevServer) {
2628
const wss = new WebSocketServer({ noServer: true })
@@ -29,7 +31,7 @@ export function setup(ctx: Vitest, _server?: ViteDevServer) {
2931

3032
const server = _server || ctx.server
3133

32-
server.httpServer?.on('upgrade', (request, socket, head) => {
34+
server.httpServer?.on('upgrade', (request: IncomingMessage, socket, head) => {
3335
if (!request.url) {
3436
return
3537
}
@@ -39,6 +41,11 @@ export function setup(ctx: Vitest, _server?: ViteDevServer) {
3941
return
4042
}
4143

44+
if (!isValidApiRequest(ctx.config, request)) {
45+
socket.destroy()
46+
return
47+
}
48+
4249
wss.handleUpgrade(request, socket, head, (ws) => {
4350
wss.emit('connection', ws, request)
4451
setupClient(ws)

packages/vitest/src/node/config/resolveConfig.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from '../types/config'
99
import type { BaseCoverageOptions, CoverageReporterWithOptions } from '../types/coverage'
1010
import type { BuiltinPool, ForksOptions, PoolOptions, ThreadsOptions } from '../types/pool-options'
11+
import crypto from 'node:crypto'
1112
import { toArray } from '@vitest/utils'
1213
import { resolveModule } from 'local-pkg'
1314
import { normalize, relative, resolve } from 'pathe'
@@ -629,7 +630,8 @@ export function resolveConfig(
629630
}
630631

631632
// the server has been created, we don't need to override vite.server options
632-
resolved.api = resolveApiServerConfig(options, defaultPort)
633+
const api = resolveApiServerConfig(options, defaultPort)
634+
resolved.api = { ...api, token: crypto.randomUUID() }
633635

634636
if (options.related) {
635637
resolved.related = toArray(options.related).map(file =>

packages/vitest/src/node/types/config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1014,7 +1014,7 @@ export interface ResolvedConfig
10141014

10151015
defines: Record<string, any>
10161016

1017-
api?: ApiConfig
1017+
api: ApiConfig & { token: string }
10181018
cliExclude?: string[]
10191019

10201020
project: string[]

packages/vitest/src/public/node.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { TestModule as _TestFile } from '../node/reporters/reported-tasks'
55

66
export const version = Vitest.version
77

8+
export { isValidApiRequest } from '../api/check'
89
export { parseCLI } from '../node/cli/cac'
910
export type { CliParseOptions } from '../node/cli/cac'
1011
export { startVitest } from '../node/cli/cli-api'

test/config/test/override.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ describe('correctly defines api flag', () => {
249249
expect(c.server.config.server.middlewareMode).toBe(true)
250250
expect(c.config.api).toEqual({
251251
middlewareMode: true,
252+
token: expect.any(String),
252253
})
253254
})
254255

@@ -262,6 +263,7 @@ describe('correctly defines api flag', () => {
262263
expect(c.server.config.server.port).toBe(4321)
263264
expect(c.config.api).toEqual({
264265
port: 4321,
266+
token: expect.any(String),
265267
})
266268
})
267269
})

test/ui/test/ui.spec.ts

+30
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,36 @@ test.describe('ui', () => {
3030
await vitest?.close()
3131
})
3232

33+
test('security', async ({ page }) => {
34+
await page.goto('https://example.com/')
35+
36+
// request html
37+
const htmlResult = await page.evaluate(async (pageUrl) => {
38+
try {
39+
const res = await fetch(pageUrl)
40+
return res.status
41+
}
42+
catch (e) {
43+
return e instanceof Error ? e.message : e
44+
}
45+
}, pageUrl)
46+
expect(htmlResult).toBe('Failed to fetch')
47+
48+
// request websocket
49+
const wsResult = await page.evaluate(async (pageUrl) => {
50+
const ws = new WebSocket(new URL('/__vitest_api__', pageUrl))
51+
return new Promise((resolve) => {
52+
ws.addEventListener('open', () => {
53+
resolve('open')
54+
})
55+
ws.addEventListener('error', () => {
56+
resolve('error')
57+
})
58+
})
59+
}, pageUrl)
60+
expect(wsResult).toBe('error')
61+
})
62+
3363
test('basic', async ({ page }) => {
3464
const pageErrors: unknown[] = []
3565
page.on('pageerror', error => pageErrors.push(error))

test/ui/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"extends": "../../tsconfig.base.json",
33
"compilerOptions": {
4+
"lib": ["ESNext", "DOM"],
45
"baseUrl": "../..",
56
"paths": {
67
"vitest/node": [

0 commit comments

Comments
 (0)