Skip to content

Commit 5622f09

Browse files
authored
fix(ui): require auth for coverage report and all UI subtree requests (#10971)
1 parent 210ba00 commit 5622f09

2 files changed

Lines changed: 70 additions & 11 deletions

File tree

packages/ui/node/index.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import type { IncomingMessage } from 'node:http'
12
import type { PluginHarness, Vite } from 'vitest/node'
3+
import crypto from 'node:crypto'
24
import fs from 'node:fs'
35
import { parse as parseCookie, serialize as serializeCookie } from 'cookie'
46
import { join, resolve } from 'pathe'
@@ -11,6 +13,7 @@ import { distClientRoot } from './paths'
1113
export { distClientRoot }
1214

1315
const UI_TOKEN_COOKIE = 'vitest-ui-token'
16+
const AUTH_REQUIRED_MESSAGE = 'Vitest UI requires authentication. Open the URL with the token printed in the terminal, e.g. http://localhost:51204/__vitest__/?token=...'
1417

1518
export default (harness: PluginHarness): Vite.Plugin => {
1619
if (harness.version !== version) {
@@ -33,6 +36,49 @@ export default (harness: PluginHarness): Vite.Plugin => {
3336
const uiOptions = ctx.config
3437
const base = uiOptions.uiBase
3538

39+
function serializeTokenCookie(): string {
40+
return serializeCookie(UI_TOKEN_COOKIE, ctx.config.api.token, {
41+
path: base,
42+
httpOnly: true,
43+
sameSite: 'strict',
44+
})
45+
}
46+
47+
function hasValidTokenCookie(req: IncomingMessage): boolean {
48+
const cookieToken = parseCookie(req.headers.cookie ?? '')[UI_TOKEN_COOKIE]
49+
if (!cookieToken) {
50+
return false
51+
}
52+
try {
53+
return crypto.timingSafeEqual(
54+
Buffer.from(cookieToken),
55+
Buffer.from(ctx.config.api.token),
56+
)
57+
}
58+
catch {
59+
return false
60+
}
61+
}
62+
63+
// Authenticate the whole UI subtree in one place. Mounted on `base` so
64+
// Connect matches it exactly like the static handlers below, which it
65+
// routes case-insensitively and on `.`/`/` boundaries; a pathname
66+
// comparison here would diverge and be bypassable (e.g. /__vitest__/Coverage).
67+
// eslint-disable-next-line prefer-arrow-callback
68+
server.middlewares.use(base, function vitestUiAuth(req, res, next) {
69+
// a valid `?token=` bootstraps the cookie so later cookie-only
70+
// requests (the coverage iframe and its child assets) stay authorized
71+
if (isValidApiRequest(ctx.config, req)) {
72+
res.setHeader('Set-Cookie', serializeTokenCookie())
73+
return next()
74+
}
75+
if (hasValidTokenCookie(req)) {
76+
return next()
77+
}
78+
res.statusCode = 403
79+
res.end(AUTH_REQUIRED_MESSAGE)
80+
})
81+
3682
// Serve coverage HTML at ./coverage if configured
3783
const coverageHtmlDir = ctx.config.coverage?.htmlDir
3884
if (coverageHtmlDir) {
@@ -98,23 +144,14 @@ export default (harness: PluginHarness): Vite.Plugin => {
98144
if (req.url) {
99145
const url = new URL(req.url, 'http://localhost')
100146
if (url.pathname === base) {
147+
// vitestUiAuth already validated the request and set the cookie;
148+
// redirect to strip the token from the URL
101149
if (isValidApiRequest(ctx.config, req)) {
102150
res.statusCode = 302
103-
res.setHeader('Set-Cookie', serializeCookie(UI_TOKEN_COOKIE, ctx.config.api.token, {
104-
path: base,
105-
httpOnly: true,
106-
sameSite: 'strict',
107-
}))
108151
res.setHeader('Location', base)
109152
res.end()
110153
return
111154
}
112-
const cookieToken = parseCookie(req.headers.cookie ?? '')[UI_TOKEN_COOKIE]
113-
if (cookieToken !== ctx.config.api.token) {
114-
res.statusCode = 403
115-
res.end('Vitest UI requires authentication. Open the URL with the token printed in the terminal, e.g. http://localhost:51204/__vitest__/?token=...')
116-
return
117-
}
118155
const html = clientIndexHtml.replace(
119156
'<!-- !LOAD_METADATA! -->',
120157
`<script>window.VITEST_API_TOKEN = ${JSON.stringify(ctx.config.api.token)}</script>`,

test/ui/test/ui.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,28 @@ test.describe('ui', () => {
6060
await expect(badToken.text()).resolves.toContain('Vitest UI requires authentication.')
6161
})
6262

63+
test('blocks unauthenticated coverage requests', async ({ request }) => {
64+
const base = new URL(pageUrl)
65+
base.search = ''
66+
67+
const entry = new URL('coverage/index.html', base).toString()
68+
const tokenless = await request.get(entry)
69+
expect(tokenless.status()).toBe(403)
70+
await expect(tokenless.text()).resolves.toContain('Vitest UI requires authentication.')
71+
72+
// Connect matches the mount case-insensitively and treats `.` as a
73+
// boundary, so these must be gated too, not just the `/`-delimited path.
74+
for (const path of ['Coverage/index.html', 'coverage.', 'coverage./index.html']) {
75+
const res = await request.get(new URL(path, base).toString())
76+
expect(res.status(), path).toBe(403)
77+
}
78+
79+
const withToken = new URL(entry)
80+
withToken.searchParams.set('token', vitest!.config.api.token)
81+
const authed = await request.get(withToken.toString())
82+
expect(authed.status()).toBe(200)
83+
})
84+
6385
test('does not serve the api token file', async ({ request }) => {
6486
const { tokenPath } = resolveApiToken(vitest!.config.root)
6587
expect(existsSync(tokenPath)).toBe(true)

0 commit comments

Comments
 (0)
Sponsor
SponsoredKunjungi sekarang
Promo