@@ -159,6 +159,100 @@ export async function httpRequestAttempt(
159159 }
160160 }
161161
162+ function handleRedirect ( res : IncomingResponse , location : string ) : void {
163+ // Drain the redirect response body to free the socket.
164+ res . resume ( )
165+
166+ emitResponse ( {
167+ headers : res . headers ,
168+ status : res . statusCode ,
169+ statusText : res . statusMessage ,
170+ } )
171+
172+ if ( maxRedirects <= 0 ) {
173+ // Hook already emitted above — reject directly to avoid double-fire.
174+ settled = true
175+ reject (
176+ new ErrorCtor (
177+ `Too many redirects (exceeded maximum: ${ maxRedirects } )` ,
178+ ) ,
179+ )
180+ return
181+ }
182+
183+ // Resolve the Location header against the current url whether it
184+ // is absolute or relative — the URL constructor ignores the base
185+ // when the first argument already parses as an absolute URL. A
186+ // scheme check on `.protocol` (not `startsWith('http')`, which
187+ // also accepts `httpfoo:`) validates the result.
188+ const redirectParsed = new URLCtor ( location , url )
189+ if (
190+ redirectParsed . protocol !== 'http:' &&
191+ redirectParsed . protocol !== 'https:'
192+ ) {
193+ // Hook already emitted above — reject directly to avoid double-fire.
194+ settled = true
195+ reject (
196+ new ErrorCtor (
197+ `Redirect Location has an unsupported scheme: ${ location } ` ,
198+ ) ,
199+ )
200+ return
201+ }
202+ const redirectUrl = redirectParsed . toString ( )
203+
204+ if ( isHttps && redirectParsed . protocol !== 'https:' ) {
205+ // Hook already emitted above — reject directly to avoid double-fire.
206+ settled = true
207+ reject (
208+ new ErrorCtor (
209+ `Redirect from HTTPS to HTTP is not allowed: ${ redirectUrl } ` ,
210+ ) ,
211+ )
212+ return
213+ }
214+
215+ // Strip auth/session headers on cross-origin redirects to prevent
216+ // leaking credentials to third-party hosts (e.g., GitHub -> S3).
217+ let redirectHeaders = headers
218+ if ( new URLCtor ( url ) . origin !== redirectParsed . origin ) {
219+ redirectHeaders = {
220+ __proto__ : null ,
221+ } as unknown as typeof headers
222+ const stripped = new Set ( [
223+ 'authorization' ,
224+ 'cookie' ,
225+ 'proxy-authenticate' ,
226+ 'proxy-authorization' ,
227+ ] )
228+ for ( const key of ObjectKeys ( headers ) ) {
229+ if ( ! stripped . has ( key . toLowerCase ( ) ) ) {
230+ ; ( redirectHeaders as Record < string , unknown > ) [ key ] = (
231+ headers as Record < string , unknown >
232+ ) [ key ]
233+ }
234+ }
235+ }
236+
237+ // Redirect chaining — Promise adoption handles the inner result.
238+ settled = true
239+ resolve (
240+ httpRequestAttempt ( redirectUrl , {
241+ body,
242+ ca,
243+ followRedirects,
244+ headers : redirectHeaders ,
245+ hooks,
246+ maxRedirects : maxRedirects - 1 ,
247+ maxResponseSize,
248+ method,
249+ stream,
250+ timeout,
251+ } ) ,
252+ )
253+ return
254+ }
255+
162256 /* c8 ignore start - External HTTP/HTTPS request */
163257 const request = httpModule . request (
164258 requestOptions ,
@@ -177,96 +271,7 @@ export async function httpRequestAttempt(
177271 res . statusCode < 400 &&
178272 res . headers . location
179273 ) {
180- // Drain the redirect response body to free the socket.
181- res . resume ( )
182-
183- emitResponse ( {
184- headers : res . headers ,
185- status : res . statusCode ,
186- statusText : res . statusMessage ,
187- } )
188-
189- if ( maxRedirects <= 0 ) {
190- // Hook already emitted above — reject directly to avoid double-fire.
191- settled = true
192- reject (
193- new ErrorCtor (
194- `Too many redirects (exceeded maximum: ${ maxRedirects } )` ,
195- ) ,
196- )
197- return
198- }
199-
200- // Resolve the Location header against the current url whether it
201- // is absolute or relative — the URL constructor ignores the base
202- // when the first argument already parses as an absolute URL. A
203- // scheme check on `.protocol` (not `startsWith('http')`, which
204- // also accepts `httpfoo:`) validates the result.
205- const redirectParsed = new URLCtor ( res . headers . location , url )
206- if (
207- redirectParsed . protocol !== 'http:' &&
208- redirectParsed . protocol !== 'https:'
209- ) {
210- // Hook already emitted above — reject directly to avoid double-fire.
211- settled = true
212- reject (
213- new ErrorCtor (
214- `Redirect Location has an unsupported scheme: ${ res . headers . location } ` ,
215- ) ,
216- )
217- return
218- }
219- const redirectUrl = redirectParsed . toString ( )
220-
221- if ( isHttps && redirectParsed . protocol !== 'https:' ) {
222- // Hook already emitted above — reject directly to avoid double-fire.
223- settled = true
224- reject (
225- new ErrorCtor (
226- `Redirect from HTTPS to HTTP is not allowed: ${ redirectUrl } ` ,
227- ) ,
228- )
229- return
230- }
231-
232- // Strip auth/session headers on cross-origin redirects to prevent
233- // leaking credentials to third-party hosts (e.g., GitHub -> S3).
234- let redirectHeaders = headers
235- if ( new URLCtor ( url ) . origin !== redirectParsed . origin ) {
236- redirectHeaders = {
237- __proto__ : null ,
238- } as unknown as typeof headers
239- const stripped = new Set ( [
240- 'authorization' ,
241- 'cookie' ,
242- 'proxy-authenticate' ,
243- 'proxy-authorization' ,
244- ] )
245- for ( const key of ObjectKeys ( headers ) ) {
246- if ( ! stripped . has ( key . toLowerCase ( ) ) ) {
247- ; ( redirectHeaders as Record < string , unknown > ) [ key ] = (
248- headers as Record < string , unknown >
249- ) [ key ]
250- }
251- }
252- }
253-
254- // Redirect chaining — Promise adoption handles the inner result.
255- settled = true
256- resolve (
257- httpRequestAttempt ( redirectUrl , {
258- body,
259- ca,
260- followRedirects,
261- headers : redirectHeaders ,
262- hooks,
263- maxRedirects : maxRedirects - 1 ,
264- maxResponseSize,
265- method,
266- stream,
267- timeout,
268- } ) ,
269- )
274+ handleRedirect ( res , res . headers . location )
270275 return
271276 }
272277
0 commit comments