Skip to content

Commit 4c26d76

Browse files
hi-ogawacodex
andauthored
fix(ui)!: harden UI API access (#10583)
Co-authored-by: Hiroshi Ogawa <[email protected]> Co-authored-by: Codex <[email protected]>
1 parent 4cbf6b4 commit 4c26d76

20 files changed

Lines changed: 208 additions & 45 deletions

File tree

docs/guide/migration.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ test('sort', async ({ bench }) => { // [!code ++]
4343
- **`benchmark.outputJson` config and the `--outputJson` CLI flag** are removed. Use `--reporter=json --outputFile=<path>` to capture benchmark results; the JSON reporter now includes a `benchmarks` field on each test case.
4444
- **`Vitest` instance `mode` property** is now always `'test'`. The previous `'benchmark'` value is no longer used; benchmarks run inside a dedicated project of the same `Vitest` instance.
4545

46+
### Vitest UI Requires an Authenticated URL
47+
48+
Vitest UI now requires token authentication for the HTML page and API access. The `/__vitest__/` URL will show an error until the browser is authenticated. To authenticate, open the URL with a token printed by Vitest, as shown below. Once authenticated, the direct `/__vitest__/` URL will work correctly.
49+
50+
```bash
51+
vitest --ui
52+
# UI started at http://localhost:51204/__vitest__/?token=...
53+
```
54+
4655
### Removed `test.sequential`, `describe.sequential`, and `sequential` Options
4756

4857
Vitest 5.0 removes the deprecated `test.sequential`, `describe.sequential`, and `sequential` test options. Use `concurrent: false` when you need a test or suite to opt out of inherited or globally configured concurrency.

docs/guide/ui.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ vitest --ui
1818

1919
Then you can visit the Vitest UI at <a href="http://localhost:51204/__vitest__/">`http://localhost:51204/__vitest__/`</a>
2020

21+
::: tip
22+
Vitest UI access is protected. If the direct URL shows an error, open the URL with a token printed by Vitest in the terminal, for example `http://localhost:51204/__vitest__/?token=...`.
23+
:::
24+
2125
::: warning
2226
The UI is interactive and requires a running Vite server, so make sure to run Vitest in `watch` mode (the default). Alternatively, you can generate a static HTML report that looks identical to the Vitest UI by specifying `html` in config's `reporters` option.
2327
:::

packages/browser/src/node/plugin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
rolldownVersion,
1818
distDir as vitestDist,
1919
} from 'vitest/node'
20+
import { API_TOKEN_FILE } from '../../../vitest/src/node/config/apiToken'
2021
import { distRoot } from './constants'
2122
import { createOrchestratorMiddleware } from './middlewares/orchestratorMiddleware'
2223
import { createTesterMiddleware } from './middlewares/testerMiddleware'
@@ -402,6 +403,8 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
402403
}
403404
viteConfig.server.fs ??= {}
404405
viteConfig.server.fs.allow = viteConfig.server.fs.allow || []
406+
viteConfig.server.fs.deny ??= []
407+
viteConfig.server.fs.deny.push(API_TOKEN_FILE)
405408
viteConfig.server.fs.allow.push(
406409
...resolveFsAllow(
407410
parentServer.vitest.config.root,

packages/ui/node/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Vite, Vitest } from 'vitest/node'
22
import fs from 'node:fs'
3+
import { parse as parseCookie, serialize as serializeCookie } from 'cookie'
34
import { join, resolve } from 'pathe'
45
import sirv from 'sirv'
56
import c from 'tinyrainbow'
@@ -9,6 +10,8 @@ import { distClientRoot } from './paths'
910

1011
export { distClientRoot }
1112

13+
const UI_TOKEN_COOKIE = 'vitest-ui-token'
14+
1215
export default (ctx: Vitest): Vite.Plugin => {
1316
if (ctx.version !== version) {
1417
ctx.logger.warn(
@@ -94,11 +97,29 @@ export default (ctx: Vitest): Vite.Plugin => {
9497
if (req.url) {
9598
const url = new URL(req.url, 'http://localhost')
9699
if (url.pathname === base) {
100+
if (isValidApiRequest(ctx.config, req)) {
101+
res.statusCode = 302
102+
res.setHeader('Set-Cookie', serializeCookie(UI_TOKEN_COOKIE, ctx.config.api.token, {
103+
path: base,
104+
httpOnly: true,
105+
sameSite: 'strict',
106+
}))
107+
res.setHeader('Location', base)
108+
res.end()
109+
return
110+
}
111+
const cookieToken = parseCookie(req.headers.cookie ?? '')[UI_TOKEN_COOKIE]
112+
if (cookieToken !== ctx.config.api.token) {
113+
res.statusCode = 403
114+
res.end('Vitest UI requires authentication. Open the URL with the token printed in the terminal, e.g. http://localhost:51204/__vitest__/?token=...')
115+
return
116+
}
97117
const html = clientIndexHtml.replace(
98118
'<!-- !LOAD_METADATA! -->',
99119
`<script>window.VITEST_API_TOKEN = ${JSON.stringify(ctx.config.api.token)}</script>`,
100120
)
101121
res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate')
122+
res.setHeader('Referrer-Policy', 'no-referrer')
102123
res.setHeader('Content-Type', 'text/html; charset=utf-8')
103124
res.write(html)
104125
res.end()

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"birpc": "catalog:",
8080
"codemirror": "^5.65.18",
8181
"codemirror-theme-vars": "^0.1.2",
82+
"cookie": "^1.0.2",
8283
"d3-graph-controller": "^3.1.8",
8384
"floating-vue": "^5.2.2",
8485
"mime": "^4.1.0",

packages/ui/vite.config.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { resolve } from 'pathe'
66
import { presetAttributify, presetIcons, presetWind3, transformerDirectives } from 'unocss'
77
import Unocss from 'unocss/vite'
88
import { defineConfig } from 'vite'
9+
import { resolveApiToken } from '../vitest/src/node/config/apiToken'
910

1011
export default defineConfig({
1112
base: './',
@@ -67,10 +68,8 @@ export default defineConfig({
6768
})
6869

6970
function devUiScriptPlugin(): Plugin {
70-
const UI_SCRIPT_RE = /<script>(window\.VITEST_API_TOKEN\s*=\s*"[^"]+")<\/script>/
7171
const BROWSER_SCRIPT_RE = /<script type="module">([\s\S]*?window\.__vitest_browser_runner__\s*=\s*\{[\s\S]*?window\.VITEST_API_TOKEN\s*=[\s\S]*?)<\/script>/
7272

73-
const uiUrl = `http://localhost:${process.env.VITE_PORT || '51204'}/__vitest__/`
7473
const browserUrl = `http://localhost:${process.env.BROWSER_DEV_PORT || '63315'}/__vitest_test__/`
7574

7675
return {
@@ -99,19 +98,11 @@ function devUiScriptPlugin(): Plugin {
9998
]
10099
}
101100

102-
const response = await fetch(uiUrl)
103-
if (!response.ok) {
104-
throw new Error(`Failed to fetch VITEST_API_TOKEN from ${uiUrl}`)
105-
}
106-
const testHtml = await response.text()
107-
const tokenScript = testHtml.match(UI_SCRIPT_RE)?.[1]
108-
if (!tokenScript) {
109-
throw new Error('Failed to extract VITEST_API_TOKEN from the response')
110-
}
101+
const token = resolveApiToken(process.cwd()).token
111102
return [
112103
{
113104
tag: 'script',
114-
children: tokenScript,
105+
children: `window.VITEST_API_TOKEN = ${JSON.stringify(token)}`,
115106
injectTo: 'head-prepend',
116107
},
117108
]
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import crypto from 'node:crypto'
2+
import {
3+
chmodSync,
4+
existsSync,
5+
mkdirSync,
6+
readFileSync,
7+
writeFileSync,
8+
} from 'node:fs'
9+
import { homedir } from 'node:os'
10+
import { dirname, join } from 'pathe'
11+
import { searchForWorkspaceRoot } from 'vite'
12+
13+
export const API_TOKEN_FILE = '.vitest-secret-token'
14+
15+
// Follows env-paths' user data directory conventions:
16+
// https://github.com/sindresorhus/env-paths/blob/v4.0.0/index.js
17+
function getUserDataDir(): string {
18+
if (process.platform === 'win32') {
19+
return process.env.LOCALAPPDATA || join(homedir(), 'AppData/Local')
20+
}
21+
if (process.platform === 'darwin') {
22+
return join(homedir(), 'Library/Application Support')
23+
}
24+
return process.env.XDG_DATA_HOME || join(homedir(), '.local/share')
25+
}
26+
27+
function resolveTokenFromPath(tokenPath: string): { token: string; tokenCreated: boolean } {
28+
if (existsSync(tokenPath)) {
29+
return { token: readFileSync(tokenPath, 'utf-8').trim(), tokenCreated: false }
30+
}
31+
32+
const token = crypto.randomUUID()
33+
mkdirSync(dirname(tokenPath), { recursive: true, mode: 0o700 })
34+
writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 })
35+
try {
36+
chmodSync(dirname(tokenPath), 0o700)
37+
chmodSync(tokenPath, 0o600)
38+
}
39+
catch {}
40+
return { token, tokenCreated: true }
41+
}
42+
43+
export function resolveApiToken(root: string): { token: string; tokenCreated: boolean; tokenPath: string } {
44+
const tokenPaths = [
45+
join(getUserDataDir(), 'vitest', API_TOKEN_FILE),
46+
join(searchForWorkspaceRoot(root), 'node_modules/.vitest', API_TOKEN_FILE),
47+
]
48+
49+
for (const tokenPath of tokenPaths) {
50+
try {
51+
return { ...resolveTokenFromPath(tokenPath), tokenPath }
52+
}
53+
catch {}
54+
}
55+
56+
throw new Error(`Failed to create Vitest API token at ${tokenPaths.join(' or ')}`)
57+
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type {
88
UserConfig,
99
} from '../types/config'
1010
import type { CoverageOptions, CoverageReporterWithOptions } from '../types/coverage'
11-
import crypto from 'node:crypto'
1211
import { existsSync, statSync } from 'node:fs'
1312
import { pathToFileURL } from 'node:url'
1413
import { slash, toArray } from '@vitest/utils/helpers'
@@ -29,6 +28,7 @@ import { getWorkersCountByPercentage } from '../../utils/workers'
2928
import { withLabel } from '../reporters/renderers/utils'
3029
import { BaseSequencer } from '../sequencers/BaseSequencer'
3130
import { RandomSequencer } from '../sequencers/RandomSequencer'
31+
import { resolveApiToken } from './apiToken'
3232

3333
function resolvePath(path: string, root: string) {
3434
// local-pkg (mlly)'s resolveModule("./file", { paths: ["/some/root"] }) tries
@@ -659,7 +659,8 @@ export function resolveConfig(
659659

660660
// the server has been created, we don't need to override vite.server options
661661
const api = resolveApiServerConfig(options, defaultPort)
662-
resolved.api = { ...api, token: crypto.randomUUID() }
662+
const { token, tokenCreated } = resolveApiToken(resolved.root)
663+
resolved.api = { ...api, token, tokenCreated }
663664

664665
if (options.related) {
665666
resolved.related = toArray(options.related).map(file =>

packages/vitest/src/node/create.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,32 @@ export async function createVitest(
7575

7676
if (ctx.config.api?.port) {
7777
await server.listen()
78+
if (ctx.config.ui && ctx.config.open) {
79+
// Note: `tokenCreated` is only an approximation of "the browser is not
80+
// authenticated yet". If the user clears cookies while the token file
81+
// persists, the clean URL will block until they re-open the `?token=`
82+
// URL printed in the terminal.
83+
if (ctx.config.api.tokenCreated) {
84+
// First run that generated the token: no browser holds the auth
85+
// cookie yet, so open the authenticated URL to set it. A new tab
86+
// here is fine since no clean-URL tab exists to reuse.
87+
const url = new URL(ctx.config.uiBase, 'http://localhost')
88+
url.searchParams.set('token', ctx.config.api.token)
89+
server.config.server.open = `${url.pathname}${url.search}`
90+
}
91+
else {
92+
// Subsequent runs: open the clean UI base URL (without `?token=`)
93+
// rather than the authenticated URL printed by the logger. On macOS,
94+
// `openBrowser` reuses an existing tab whose URL matches via substring
95+
// and reloads it (Vite's `bin/openChrome.js`). Since the 302 redirect
96+
// strips the token, an already-authenticated tab lives at the clean
97+
// URL, so opening the clean URL matches and reloads it; opening the
98+
// token URL would never match and would spawn a new tab on every
99+
// restart.
100+
server.config.server.open = ctx.config.uiBase
101+
}
102+
server.openBrowser()
103+
}
78104
}
79105

80106
return ctx

packages/vitest/src/node/logger.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,10 @@ export class Logger {
243243
if (this.ctx.config.ui) {
244244
const host = this.ctx.config.api?.host || 'localhost'
245245
const port = this.ctx.vite.config.server.port
246-
const base = this.ctx.config.uiBase
246+
const url = new URL(this.ctx.config.uiBase, `http://${host}:${port}`)
247+
url.searchParams.set('token', this.ctx.config.api.token)
247248

248-
this.log(PAD + c.dim(c.green(`UI started at http://${host}:${c.bold(port)}${base}`)))
249+
this.log(PAD + c.dim(c.green(`UI started at ${url}`)))
249250
}
250251
else if (this.ctx.config.api?.port) {
251252
const resolvedUrls = this.ctx.vite.resolvedUrls

0 commit comments

Comments
 (0)