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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 6 additions & 1 deletion
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 23 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 22 additions & 0 deletions
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

Lines changed: 8 additions & 1 deletion
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)

0 commit comments

Comments
 (0)