@@ -82,9 +82,9 @@ export function openBrowser(url: string): void {
8282 execFile ( 'open' , [ url ] ) ;
8383 break ;
8484 case 'win32' :
85- // cmd.exe interprets '&' in URLs as a command separator, truncating the URL.
86- // PowerShell's Start-Process passes the URL as a single argument without shell interpretation .
87- execFile ( 'powershell.exe' , [ '-NoProfile' , '-Command' , ` Start-Process ' ${ url } '` ] ) ;
85+ // Pass the URL via $args[0] so it is never interpolated into the -Command
86+ // string — avoids quote-breaking and injection risk from special characters .
87+ execFile ( 'powershell.exe' , [ '-NoProfile' , '-Command' , ' Start-Process $args[0]' , '-args' , url ] ) ;
8888 break ;
8989 default :
9090 execFile ( 'xdg-open' , [ url ] ) ;
@@ -97,13 +97,27 @@ export function openBrowser(url: string): void {
9797 * Spin up a temporary localhost HTTP server that accepts exactly one callback
9898 * from Cognito's Hosted UI, extracts the auth code, and shuts down.
9999 */
100- export function listenForCallback ( port : number ) : Promise < string > {
100+ export function listenForCallback ( port : number , expectedState ?: string ) : Promise < string > {
101101 return new Promise ( ( resolve , reject ) => {
102102 const server = http . createServer ( ( req , res ) => {
103103 const parsed = new URL ( req . url ?? '/' , `http://localhost:${ port } ` ) ;
104104 const code = parsed . searchParams . get ( 'code' ) ;
105105 const error = parsed . searchParams . get ( 'error' ) ;
106106 const description = parsed . searchParams . get ( 'error_description' ) ;
107+ const callbackState = parsed . searchParams . get ( 'state' ) ;
108+
109+ if ( expectedState && callbackState !== expectedState ) {
110+ res . writeHead ( 400 , { 'Content-Type' : 'text/html; charset=utf-8' } ) ;
111+ res . end (
112+ '<html><body style="font-family:sans-serif;padding:2rem;max-width:480px">' +
113+ '<h2 style="color:#c23934">Authentication failed</h2>' +
114+ '<p>Invalid state parameter — possible CSRF attack. Please try again.</p>' +
115+ '</body></html>'
116+ ) ;
117+ server . close ( ) ;
118+ reject ( new Error ( 'OAuth callback state mismatch — possible CSRF. Try again.' ) ) ;
119+ return ;
120+ }
107121
108122 res . writeHead ( 200 , { 'Content-Type' : 'text/html; charset=utf-8' } ) ;
109123 res . end (
@@ -166,6 +180,8 @@ export async function exchangeCodeForTokens(opts: {
166180
167181// ── Internal HTTPS helper ─────────────────────────────────────────────────────
168182
183+ const REQUEST_TIMEOUT_MS = 30_000 ;
184+
169185function httpsPost (
170186 url : string ,
171187 body : string ,
@@ -176,6 +192,7 @@ function httpsPost(
176192 const req = https . request (
177193 {
178194 hostname : parsed . hostname ,
195+ port : parsed . port || undefined ,
179196 path : parsed . pathname + parsed . search ,
180197 method : 'POST' ,
181198 headers : {
@@ -191,6 +208,9 @@ function httpsPost(
191208 res . on ( 'end' , ( ) => resolve ( { status : res . statusCode ?? 0 , responseBody : data } ) ) ;
192209 }
193210 ) ;
211+ req . setTimeout ( REQUEST_TIMEOUT_MS , ( ) => {
212+ req . destroy ( new Error ( `Cognito token exchange timed out after ${ REQUEST_TIMEOUT_MS / 1000 } s` ) ) ;
213+ } ) ;
194214 req . on ( 'error' , reject ) ;
195215 req . write ( body ) ;
196216 req . end ( ) ;
@@ -208,6 +228,6 @@ export const loginFlowClient = {
208228 generateState,
209229 findAvailablePort,
210230 openBrowser,
211- listenForCallback,
231+ listenForCallback : listenForCallback as ( port : number , expectedState ?: string ) => Promise < string > ,
212232 exchangeCodeForTokens,
213233} ;
0 commit comments