11import { describe , expect , it } from "vitest" ;
22
3- import { authPlugin , handleAuthRequest , isAuthenticatedRequest } from "../vite-auth" ;
3+ import {
4+ AUTH_BOOTSTRAP_PATH ,
5+ authPlugin ,
6+ handleAuthRequest ,
7+ handleBootstrapRequest ,
8+ isAuthenticatedRequest ,
9+ } from "../vite-auth" ;
410
511const TOKEN = "deterministic-test-token" ;
612
@@ -25,6 +31,13 @@ describe("isAuthenticatedRequest (#4858)", () => {
2531 expect ( isAuthenticatedRequest ( "loopover_miner_ui_token=wrong-value" , TOKEN ) ) . toBe ( false ) ;
2632 } ) ;
2733
34+ it ( "rejects the auth cookie when its value is a different length than the server's token" , ( ) => {
35+ // Exercises the length-mismatch short-circuit ahead of timingSafeEqual, which throws on unequal-length
36+ // buffers -- a shorter or longer guess must be rejected, not crash the request handler.
37+ expect ( isAuthenticatedRequest ( "loopover_miner_ui_token=short" , TOKEN ) ) . toBe ( false ) ;
38+ expect ( isAuthenticatedRequest ( `loopover_miner_ui_token=${ TOKEN } -and-then-some` , TOKEN ) ) . toBe ( false ) ;
39+ } ) ;
40+
2841 it ( "accepts the auth cookie when its value matches the server's token exactly" , ( ) => {
2942 expect ( isAuthenticatedRequest ( `loopover_miner_ui_token=${ TOKEN } ` , TOKEN ) ) . toBe ( true ) ;
3043 } ) ;
@@ -52,15 +65,46 @@ describe("handleAuthRequest (#4858)", () => {
5265 } ) ;
5366} ) ;
5467
68+ describe ( "handleBootstrapRequest (GHSA-v6v4-mh5m-5mqq)" , ( ) => {
69+ it ( "falls through (null) for a request with no url" , ( ) => {
70+ expect ( handleBootstrapRequest ( undefined , TOKEN ) ) . toBeNull ( ) ;
71+ } ) ;
72+
73+ it ( "falls through (null) for any path other than the bootstrap path" , ( ) => {
74+ expect ( handleBootstrapRequest ( "/" , TOKEN ) ) . toBeNull ( ) ;
75+ expect ( handleBootstrapRequest ( "/api/portfolio-queue" , TOKEN ) ) . toBeNull ( ) ;
76+ } ) ;
77+
78+ it ( "redirects to / when the bootstrap path is visited with the correct token" , ( ) => {
79+ expect ( handleBootstrapRequest ( `${ AUTH_BOOTSTRAP_PATH } ?token=${ TOKEN } ` , TOKEN ) ) . toEqual ( { redirectTo : "/" } ) ;
80+ } ) ;
81+
82+ it ( "returns a 404 (not a 401) for the bootstrap path with a missing token" , ( ) => {
83+ expect ( handleBootstrapRequest ( AUTH_BOOTSTRAP_PATH , TOKEN ) ) . toEqual ( { status : 404 , body : "Not Found" } ) ;
84+ } ) ;
85+
86+ it ( "returns a 404 for the bootstrap path with the wrong token" , ( ) => {
87+ expect ( handleBootstrapRequest ( `${ AUTH_BOOTSTRAP_PATH } ?token=wrong` , TOKEN ) ) . toEqual ( {
88+ status : 404 ,
89+ body : "Not Found" ,
90+ } ) ;
91+ } ) ;
92+ } ) ;
93+
5594type CapturedRequestHandler = (
5695 req : { url ?: string ; headers : { cookie ?: string } } ,
5796 res : { statusCode : number ; setHeader : ( k : string , v : string ) => void ; end : ( body : string ) => void } ,
5897 next : ( ) => void ,
5998) => void ;
6099
61- function captureMiddleware ( deps = { generateToken : ( ) => TOKEN } ) : CapturedRequestHandler {
100+ function captureMiddleware (
101+ deps : { generateToken ?: ( ) => string ; logToken ?: ( token : string ) => void } = {
102+ generateToken : ( ) => TOKEN ,
103+ logToken : ( ) => { } ,
104+ } ,
105+ ) : CapturedRequestHandler {
62106 let captured : CapturedRequestHandler | undefined ;
63- const plugin = authPlugin ( deps ) ;
107+ const plugin = authPlugin ( { logToken : ( ) => { } , ... deps } ) ;
64108 const server = { middlewares : { use : ( fn : CapturedRequestHandler ) => ( captured = fn ) } } ;
65109 // @ts -expect-error -- the test double only implements the subset of Vite's ViteDevServer this plugin reads.
66110 plugin . configureServer ( server ) ;
@@ -93,23 +137,21 @@ function fakeResponse() {
93137 } ;
94138}
95139
96- describe ( "authPlugin (#4858)" , ( ) => {
97- it ( "stamps the Set-Cookie header on a non-/api/ request that falls through (the initial page load)" , ( ) => {
140+ describe ( "authPlugin (#4858, hardened for GHSA-v6v4-mh5m-5mqq)" , ( ) => {
141+ it ( "regression: NEVER stamps Set-Cookie on an unauthenticated, non-bootstrap request -- not even the initial page load" , ( ) => {
142+ // This is the exact vulnerability: an earlier version stamped Set-Cookie on every response that
143+ // wasn't itself rejected, including a plain unauthenticated `GET /`. That let ANY local process --
144+ // not just the intended browser -- read a valid, replayable session token off one unauthenticated
145+ // request (`curl http://127.0.0.1:4174/`), then use it against the mutating /api/* routes.
98146 const middleware = captureMiddleware ( ) ;
99- const { res, headers } = fakeResponse ( ) ;
100- let calledNext = false ;
101- middleware ( { url : "/" , headers : { } } , res , ( ) => {
102- calledNext = true ;
103- } ) ;
104- expect ( headers [ "Set-Cookie" ] ) . toBe ( `loopover_miner_ui_token=${ TOKEN } ; HttpOnly; SameSite=Strict; Path=/` ) ;
105- expect ( calledNext ) . toBe ( true ) ;
147+ for ( const url of [ "/" , "/index.html" , "/assets/index.js" , "/api/portfolio-queue" ] ) {
148+ const { res, headers } = fakeResponse ( ) ;
149+ middleware ( { url, headers : { } } , res , ( ) => { } ) ;
150+ expect ( headers [ "Set-Cookie" ] , `Set-Cookie must be absent for unauthenticated ${ url } ` ) . toBeUndefined ( ) ;
151+ }
106152 } ) ;
107153
108- it ( "rejects an unauthenticated /api/* request with 401, never calls next(), and NEVER leaks the token via Set-Cookie" , ( ) => {
109- // Regression test: an earlier version set Set-Cookie on every response BEFORE checking auth, so an
110- // unauthenticated caller could read the valid token straight off this 401's own headers and replay it --
111- // completely defeating the mechanism. The token must only ever reach a caller that is already
112- // authenticated, or a non-/api/* (page/asset) request.
154+ it ( "rejects an unauthenticated /api/* request with 401, never calls next(), and never leaks the token via Set-Cookie" , ( ) => {
113155 const middleware = captureMiddleware ( ) ;
114156 const { res, headers, getEnded, getStatus } = fakeResponse ( ) ;
115157 let calledNext = false ;
@@ -135,6 +177,44 @@ describe("authPlugin (#4858)", () => {
135177 expect ( headers [ "Set-Cookie" ] ) . toBe ( `loopover_miner_ui_token=${ TOKEN } ; HttpOnly; SameSite=Strict; Path=/` ) ;
136178 } ) ;
137179
180+ it ( "re-stamps the cookie on an already-authenticated non-/api/ request too (keeps the session rolling)" , ( ) => {
181+ const middleware = captureMiddleware ( ) ;
182+ const { res, headers } = fakeResponse ( ) ;
183+ let calledNext = false ;
184+ middleware ( { url : "/" , headers : { cookie : `loopover_miner_ui_token=${ TOKEN } ` } } , res , ( ) => {
185+ calledNext = true ;
186+ } ) ;
187+ expect ( calledNext ) . toBe ( true ) ;
188+ expect ( headers [ "Set-Cookie" ] ) . toBe ( `loopover_miner_ui_token=${ TOKEN } ; HttpOnly; SameSite=Strict; Path=/` ) ;
189+ } ) ;
190+
191+ it ( "mints the cookie and redirects to / when the bootstrap path is visited with the correct token" , ( ) => {
192+ const middleware = captureMiddleware ( ) ;
193+ const { res, headers, getStatus } = fakeResponse ( ) ;
194+ let calledNext = false ;
195+ middleware ( { url : `/__auth/bootstrap?token=${ TOKEN } ` , headers : { } } , res , ( ) => {
196+ calledNext = true ;
197+ } ) ;
198+ expect ( getStatus ( ) ) . toBe ( 302 ) ;
199+ expect ( headers . Location ) . toBe ( "/" ) ;
200+ expect ( headers [ "Set-Cookie" ] ) . toBe ( `loopover_miner_ui_token=${ TOKEN } ; HttpOnly; SameSite=Strict; Path=/` ) ;
201+ expect ( calledNext ) . toBe ( false ) ;
202+ } ) ;
203+
204+ it ( "404s the bootstrap path (never sets a cookie) when the token is missing or wrong" , ( ) => {
205+ const middleware = captureMiddleware ( ) ;
206+
207+ const missing = fakeResponse ( ) ;
208+ middleware ( { url : "/__auth/bootstrap" , headers : { } } , missing . res , ( ) => { } ) ;
209+ expect ( missing . getStatus ( ) ) . toBe ( 404 ) ;
210+ expect ( missing . headers [ "Set-Cookie" ] ) . toBeUndefined ( ) ;
211+
212+ const wrong = fakeResponse ( ) ;
213+ middleware ( { url : "/__auth/bootstrap?token=wrong" , headers : { } } , wrong . res , ( ) => { } ) ;
214+ expect ( wrong . getStatus ( ) ) . toBe ( 404 ) ;
215+ expect ( wrong . headers [ "Set-Cookie" ] ) . toBeUndefined ( ) ;
216+ } ) ;
217+
138218 it ( "uses deps.generateToken so a fixed test token is deterministic across requests" , ( ) => {
139219 const middleware = captureMiddleware ( { generateToken : ( ) => "fixed-token-123" } ) ;
140220 const { res } = fakeResponse ( ) ;
@@ -145,9 +225,28 @@ describe("authPlugin (#4858)", () => {
145225 expect ( calledNext ) . toBe ( true ) ;
146226 } ) ;
147227
228+ it ( "calls deps.logToken once with the generated token so an operator can complete the bootstrap flow" , ( ) => {
229+ const logged : string [ ] = [ ] ;
230+ authPlugin ( { generateToken : ( ) => "logged-token" , logToken : ( token ) => logged . push ( token ) } ) ;
231+ expect ( logged ) . toEqual ( [ "logged-token" ] ) ;
232+ } ) ;
233+
234+ it ( "uses console.log as the default logToken when no override is given" , ( ) => {
235+ const originalLog = console . log ;
236+ const calls : unknown [ ] [ ] = [ ] ;
237+ console . log = ( ...args : unknown [ ] ) => calls . push ( args ) ;
238+ try {
239+ authPlugin ( { generateToken : ( ) => "console-logged-token" } ) ;
240+ } finally {
241+ console . log = originalLog ;
242+ }
243+ expect ( calls ) . toHaveLength ( 1 ) ;
244+ expect ( String ( calls [ 0 ] ?. [ 0 ] ) ) . toContain ( "console-logged-token" ) ;
245+ } ) ;
246+
148247 it ( "also attaches via configurePreviewServer for `vite preview`" , ( ) => {
149248 let captured : CapturedRequestHandler | undefined ;
150- const plugin = authPlugin ( ) ;
249+ const plugin = authPlugin ( { logToken : ( ) => { } } ) ;
151250 const server = { middlewares : { use : ( fn : CapturedRequestHandler ) => ( captured = fn ) } } ;
152251 // @ts -expect-error -- same partial test double as configureServer above.
153252 plugin . configurePreviewServer ( server ) ;
0 commit comments