[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix: validate websocket request (#7317)

Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>

authored by

Hiroshi Ogawa
Ari Perkkiö
and committed by
GitHub
(Feb 2, 2025, 10:31 AM +0200) 191ef9e3 c82387d3

+103 -6
+1
packages/vitest/rollup.config.js
··· 72 72 'node:os', 73 73 'node:stream', 74 74 'node:vm', 75 + 'node:http', 75 76 'inspector', 76 77 'vite-node/source-map', 77 78 'vite-node/client',
+1
test/ui/tsconfig.json
··· 1 1 { 2 2 "extends": "../../tsconfig.base.json", 3 3 "compilerOptions": { 4 + "lib": ["ESNext", "DOM"], 4 5 "baseUrl": "../..", 5 6 "paths": { 6 7 "vitest/node": [
+1 -1
packages/ui/client/constants.ts
··· 4 4 export const HOST = [location.hostname, PORT].filter(Boolean).join(':') 5 5 export const ENTRY_URL = `${ 6 6 location.protocol === 'https:' ? 'wss:' : 'ws:' 7 - }//${HOST}/__vitest_api__` 7 + }//${HOST}/__vitest_api__?token=${(window as any).VITEST_API_TOKEN}` 8 8 export const isReport = !!window.METADATA_PATH 9 9 export const BASE_PATH = isReport ? import.meta.env.BASE_URL : __BASE_PATH__
+23
packages/ui/node/index.ts
··· 1 1 import type { Plugin } from 'vite' 2 2 import type { Vitest } from 'vitest/node' 3 + import fs from 'node:fs' 3 4 import { fileURLToPath } from 'node:url' 4 5 import { toArray } from '@vitest/utils' 5 6 import { basename, resolve } from 'pathe' ··· 52 53 } 53 54 54 55 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 + 55 78 server.middlewares.use( 56 79 base, 57 80 sirv(clientDist, {
+2
test/config/test/override.test.ts
··· 249 249 expect(c.server.config.server.middlewareMode).toBe(true) 250 250 expect(c.config.api).toEqual({ 251 251 middlewareMode: true, 252 + token: expect.any(String), 252 253 }) 253 254 }) 254 255 ··· 262 263 expect(c.server.config.server.port).toBe(4321) 263 264 expect(c.config.api).toEqual({ 264 265 port: 4321, 266 + token: expect.any(String), 265 267 }) 266 268 }) 267 269 })
+30
test/ui/test/ui.spec.ts
··· 30 30 await vitest?.close() 31 31 }) 32 32 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 + 33 63 test('basic', async ({ page }) => { 34 64 const pageErrors: unknown[] = [] 35 65 page.on('pageerror', error => pageErrors.push(error))
+1 -1
packages/browser/src/client/client.ts
··· 15 15 const METHOD = getBrowserState().method 16 16 export const ENTRY_URL = `${ 17 17 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}` 19 19 20 20 let setCancel = (_: CancelReason) => {} 21 21 export const onCancel = new Promise<CancelReason>((resolve) => {
+6 -1
packages/browser/src/node/rpc.ts
··· 10 10 import { createBirpc } from 'birpc' 11 11 import { parse, stringify } from 'flatted' 12 12 import { dirname } from 'pathe' 13 - import { createDebugger, isFileServingAllowed } from 'vitest/node' 13 + import { createDebugger, isFileServingAllowed, isValidApiRequest } from 'vitest/node' 14 14 import { WebSocketServer } from 'ws' 15 15 16 16 const debug = createDebugger('vitest:browser:api') ··· 30 30 31 31 const { pathname, searchParams } = new URL(request.url, 'http://localhost') 32 32 if (pathname !== BROWSER_API_PATH) { 33 + return 34 + } 35 + 36 + if (!isValidApiRequest(vitest.config, request)) { 37 + socket.destroy() 33 38 return 34 39 } 35 40
+1
packages/browser/src/node/serverOrchestrator.ts
··· 42 42 __VITEST_SESSION_ID__: JSON.stringify(sessionId), 43 43 __VITEST_TESTER_ID__: '"none"', 44 44 __VITEST_PROVIDED_CONTEXT__: '{}', 45 + __VITEST_API_TOKEN__: JSON.stringify(globalServer.vitest.config.api.token), 45 46 }) 46 47 47 48 // disable CSP for the orchestrator as we are the ones controlling it
+1
packages/browser/src/node/serverTester.ts
··· 68 68 __VITEST_SESSION_ID__: JSON.stringify(sessionId), 69 69 __VITEST_TESTER_ID__: JSON.stringify(crypto.randomUUID()), 70 70 __VITEST_PROVIDED_CONTEXT__: JSON.stringify(stringify(project.getProvidedContext())), 71 + __VITEST_API_TOKEN__: JSON.stringify(globalServer.vitest.config.api.token), 71 72 }) 72 73 73 74 const testerHtml = typeof browserProject.testerHtml === 'string'
+22
packages/vitest/src/api/check.ts
··· 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 + }
+8 -1
packages/vitest/src/api/setup.ts
··· 1 1 import type { File, TaskResultPack } from '@vitest/runner' 2 2 3 + import type { IncomingMessage } from 'node:http' 3 4 import type { ViteDevServer } from 'vite' 4 5 import type { WebSocket } from 'ws' 5 6 import type { Vitest } from '../node/core' ··· 21 22 import { getModuleGraph } from '../utils/graph' 22 23 import { stringifyReplace } from '../utils/serialization' 23 24 import { parseErrorStacktrace } from '../utils/source-map' 25 + import { isValidApiRequest } from './check' 24 26 25 27 export function setup(ctx: Vitest, _server?: ViteDevServer) { 26 28 const wss = new WebSocketServer({ noServer: true }) ··· 29 31 30 32 const server = _server || ctx.server 31 33 32 - server.httpServer?.on('upgrade', (request, socket, head) => { 34 + server.httpServer?.on('upgrade', (request: IncomingMessage, socket, head) => { 33 35 if (!request.url) { 34 36 return 35 37 } 36 38 37 39 const { pathname } = new URL(request.url, 'http://localhost') 38 40 if (pathname !== API_PATH) { 41 + return 42 + } 43 + 44 + if (!isValidApiRequest(ctx.config, request)) { 45 + socket.destroy() 39 46 return 40 47 } 41 48
+1
packages/vitest/src/public/node.ts
··· 5 5 6 6 export const version = Vitest.version 7 7 8 + export { isValidApiRequest } from '../api/check' 8 9 export { parseCLI } from '../node/cli/cac' 9 10 export type { CliParseOptions } from '../node/cli/cac' 10 11 export { startVitest } from '../node/cli/cli-api'
+1
packages/browser/src/client/public/esm-client-injector.js
··· 30 30 method: { __VITEST_METHOD__ }, 31 31 providedContext: { __VITEST_PROVIDED_CONTEXT__ }, 32 32 }; 33 + window.VITEST_API_TOKEN = { __VITEST_API_TOKEN__ }; 33 34 34 35 const config = __vitest_browser_runner__.config; 35 36
+3 -1
packages/vitest/src/node/config/resolveConfig.ts
··· 8 8 } from '../types/config' 9 9 import type { BaseCoverageOptions, CoverageReporterWithOptions } from '../types/coverage' 10 10 import type { BuiltinPool, ForksOptions, PoolOptions, ThreadsOptions } from '../types/pool-options' 11 + import crypto from 'node:crypto' 11 12 import { toArray } from '@vitest/utils' 12 13 import { resolveModule } from 'local-pkg' 13 14 import { normalize, relative, resolve } from 'pathe' ··· 629 630 } 630 631 631 632 // 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() } 633 635 634 636 if (options.related) { 635 637 resolved.related = toArray(options.related).map(file =>
+1 -1
packages/vitest/src/node/types/config.ts
··· 1014 1014 1015 1015 defines: Record<string, any> 1016 1016 1017 - api?: ApiConfig 1017 + api: ApiConfig & { token: string } 1018 1018 cliExclude?: string[] 1019 1019 1020 1020 project: string[]