1+ import type { IncomingMessage } from 'node:http'
12import type { PluginHarness , Vite } from 'vitest/node'
3+ import crypto from 'node:crypto'
24import fs from 'node:fs'
35import { parse as parseCookie , serialize as serializeCookie } from 'cookie'
46import { join , resolve } from 'pathe'
@@ -11,6 +13,7 @@ import { distClientRoot } from './paths'
1113export { distClientRoot }
1214
1315const 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
1518export 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>` ,
0 commit comments