@@ -27,8 +27,8 @@ import calculateRetryDelay from './calculate-retry-delay.js';
2727import Options , {
2828 crossOriginStripHeaders ,
2929 hasExplicitCredentialInUrlChange ,
30- isCrossOriginCredentialChanged ,
3130 isBodyUnchanged ,
31+ isCrossOriginCredentialChanged ,
3232 isSameOrigin ,
3333 snapshotCrossOriginState ,
3434 type CrossOriginState ,
@@ -191,6 +191,29 @@ const proxiedRequestEvents = [
191191
192192const noop = ( ) : void => { } ;
193193
194+ type NativeFormDataBodyMetadata = {
195+ form : FormData ;
196+ body : ReadableStream ;
197+ contentTypeWasGenerated : boolean ;
198+ } ;
199+
200+ const serializeNativeFormDataBody = ( form : FormData ) => {
201+ const response = new globalThis . Response ( form ) ;
202+ return {
203+ body : response . body ! ,
204+ contentType : response . headers . get ( 'content-type' ) ?? 'multipart/form-data' ,
205+ } ;
206+ } ;
207+
208+ // A body is replayable only if iterating it again restarts from the beginning.
209+ // Node streams, Web `ReadableStream`s, generators, and self-iterating (one-shot) iterators all yield their data only once, so they cannot be replayed on a redirect.
210+ const isNonReplayableBody = ( body : unknown ) : boolean =>
211+ is . nodeStream ( body )
212+ || ( typeof ReadableStream !== 'undefined' && body instanceof ReadableStream )
213+ || is . generator ( body )
214+ || ( is . asyncIterable ( body ) && ( body [ Symbol . asyncIterator ] ( ) as unknown ) === body )
215+ || ( is . iterable ( body ) && ( body [ Symbol . iterator ] ( ) as unknown ) === body ) ;
216+
194217const isTransientWriteError = ( error : Error ) : boolean => {
195218 const { code} = error ;
196219 return typeof code === 'string' && transientWriteErrorCodes . has ( code ) ;
@@ -293,6 +316,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
293316 private _request ?: ClientRequest ;
294317 private _responseSize ?: number ;
295318 private _bodySize ?: number ;
319+ private _nativeFormDataBody ?: NativeFormDataBodyMetadata ;
296320 private _unproxyEvents ?: ( ) => void ;
297321 private _triggerRead = false ;
298322 private readonly _jobs : Array < ( ) => void > = [ ] ;
@@ -303,6 +327,8 @@ export default class Request extends Duplex implements RequestEvents<Request> {
303327 private _expectedContentLength ?: number ;
304328 private _compressedBytesCount ?: number ;
305329 private _skipRequestEndInFinal = false ;
330+ private _hasWrittenBody = false ;
331+ private _hasWritableBody = false ;
306332 private _incrementalDecode ?: { decoder : TextDecoder ; chunks : string [ ] } ;
307333 private readonly _requestId = generateRequestId ( ) ;
308334
@@ -416,6 +442,8 @@ export default class Request extends Duplex implements RequestEvents<Request> {
416442 return ;
417443 }
418444
445+ this . _hasWritableBody = this . _canWriteBody ( ) ;
446+
419447 await this . _makeRequest ( ) ;
420448
421449 if ( this . destroyed ) {
@@ -672,6 +700,8 @@ export default class Request extends Duplex implements RequestEvents<Request> {
672700 }
673701
674702 override _write ( chunk : unknown , encoding : BufferEncoding | undefined , callback : ( error ?: Error | null ) => void ) : void { // eslint-disable-line @typescript-eslint/no-restricted-types
703+ this . _hasWrittenBody = true ;
704+
675705 const write = ( ) : void => {
676706 this . _writeRequest ( chunk , encoding , callback ) ;
677707 } ;
@@ -696,6 +726,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
696726 // We need to check if `this._request` is present,
697727 // because it isn't when we use cache.
698728 if ( ! request || request . destroyed ) {
729+ this . _hasWritableBody = false ;
699730 callback ( ) ;
700731 return ;
701732 }
@@ -711,6 +742,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
711742 this . _emitUploadComplete ( request ) ;
712743 }
713744
745+ this . _hasWritableBody = false ;
714746 callback ( error ) ;
715747 } ) ;
716748 } ;
@@ -850,7 +882,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
850882 // eslint-disable-next-line @typescript-eslint/naming-convention
851883 const isJSON = ! is . undefined ( options . json ) ;
852884 const isBody = ! is . undefined ( options . body ) ;
853- const cannotHaveBody = methodsWithoutBody . has ( options . method ) && ! ( options . method === 'GET' && options . allowGetBody ) ;
885+ const cannotHaveBody = ! this . _methodCanHaveBody ;
854886
855887 if ( isForm || isJSON || isBody ) {
856888 if ( cannotHaveBody ) {
@@ -863,13 +895,18 @@ export default class Request extends Duplex implements RequestEvents<Request> {
863895 if ( isBody ) {
864896 // Native FormData
865897 if ( options . body instanceof FormData ) {
866- const response = new Response ( options . body ) ;
898+ const { body, contentType} = serializeNativeFormDataBody ( options . body ) ;
899+ this . _nativeFormDataBody = {
900+ form : options . body ,
901+ body,
902+ contentTypeWasGenerated : noContentType ,
903+ } ;
867904
868905 if ( noContentType ) {
869- headers [ 'content-type' ] = response . headers . get ( 'content-type' ) ?? 'multipart/form-data' ;
906+ headers [ 'content-type' ] = contentType ;
870907 }
871908
872- options . body = response . body ! ;
909+ options . body = body ;
873910 } else if ( Object . prototype . toString . call ( options . body ) === '[object FormData]' ) {
874911 throw new TypeError ( 'Non-native FormData is not supported. Use globalThis.FormData instead.' ) ;
875912 }
@@ -1176,16 +1213,20 @@ export default class Request extends Duplex implements RequestEvents<Request> {
11761213 if ( shouldDropBody ) {
11771214 updatedOptions . method = 'GET' ;
11781215 this . _dropBody ( updatedOptions ) ;
1216+ } else if (
1217+ isDifferentOrigin
1218+ && canRewrite
1219+ && this . _hasBodyForRedirect ( updatedOptions )
1220+ ) {
1221+ this . _dropBody ( updatedOptions ) ;
11791222 }
11801223
11811224 if ( isDifferentOrigin ) {
1182- // Also strip body on cross-origin redirects to prevent data leakage.
1183- // 301/302 POST already drops the body (converted to GET above).
1184- // 307/308 preserve the method per RFC, but the body must not be
1185- // forwarded to a different origin.
1186- // Strip credentials embedded in the redirect URL itself
1187- // to prevent a malicious server from injecting auth to third parties.
1188- this . _stripCrossOriginState ( updatedOptions , redirectUrl , shouldDropBody ) ;
1225+ // On cross-origin redirects, strip sensitive headers and any credentials
1226+ // embedded in the redirect URL itself to prevent a malicious server from
1227+ // leaking them to a third party. The request body is preserved per RFC:
1228+ // 307/308 keep the method and replayable bodies, even cross-origin.
1229+ this . _stripCrossOriginState ( updatedOptions , redirectUrl ) ;
11891230 } else {
11901231 redirectUrl . username = updatedOptions . username ;
11911232 redirectUrl . password = updatedOptions . password ;
@@ -1195,6 +1236,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
11951236
11961237 this . redirectUrls . push ( redirectUrl ) ;
11971238
1239+ const bodyBeforeRedirectHooks = updatedOptions . body ;
11981240 const preHookState = isDifferentOrigin
11991241 ? undefined
12001242 : {
@@ -1213,14 +1255,68 @@ export default class Request extends Duplex implements RequestEvents<Request> {
12131255
12141256 updatedOptions . clearUnchangedCookieHeader ( preHookState , changedState ) ;
12151257
1258+ const nativeFormDataBody = this . _nativeFormDataBody ;
1259+
1260+ if ( statusCode === 307 || statusCode === 308 ) {
1261+ const bodyUnchangedByHooks = updatedOptions . body === bodyBeforeRedirectHooks ;
1262+ const wasNonReplayable = isNonReplayableBody ( bodyBeforeRedirectHooks ) ;
1263+
1264+ if ( ! bodyUnchangedByHooks && wasNonReplayable ) {
1265+ // A hook supplied a fresh body, so dispose of the original non-replayable one.
1266+ this . _destroyBody ( bodyBeforeRedirectHooks ) ;
1267+ } else if (
1268+ bodyUnchangedByHooks
1269+ && nativeFormDataBody !== undefined
1270+ && updatedOptions . body === nativeFormDataBody . body
1271+ ) {
1272+ // Native FormData generates a fresh stream and boundary, so re-serialize it to replay the upload.
1273+ const { body, contentType} = serializeNativeFormDataBody ( nativeFormDataBody . form ) ;
1274+
1275+ nativeFormDataBody . body = body ;
1276+ updatedOptions . body = body ;
1277+
1278+ if ( changedState . has ( 'content-type' ) ) {
1279+ nativeFormDataBody . contentTypeWasGenerated = false ;
1280+ } else if ( nativeFormDataBody . contentTypeWasGenerated ) {
1281+ updatedOptions . setInternalHeader ( 'content-type' , contentType ) ;
1282+ }
1283+ } else if (
1284+ bodyUnchangedByHooks
1285+ && ( wasNonReplayable || ( is . undefined ( updatedOptions . body ) && ( this . _hasWrittenBody || this . _hasWritableBody ) ) )
1286+ ) {
1287+ // 307/308 redirects must replay the body, so follow the HTTP spec and other clients by failing for unchanged non-replayable bodies. Hooks may supply a fresh body.
1288+ this . _dropBody ( updatedOptions ) ;
1289+ this . _beforeError ( new RequestError ( 'Cannot follow redirect with a non-replayable body' , { } , this ) ) ;
1290+ return ;
1291+ }
1292+ }
1293+
12161294 // If a beforeRedirect hook changed the URL to a different origin,
12171295 // strip sensitive headers that were preserved for the original origin.
12181296 // When isDifferentOrigin was already true, headers were already stripped above.
12191297 if ( ! isDifferentOrigin ) {
12201298 const state = preHookState ! ;
12211299 const hookUrl = updatedOptions . url ;
1222- if ( ! isSameOrigin ( state . url , hookUrl ) ) {
1223- this . _stripUnchangedCrossOriginState ( updatedOptions , hookUrl , shouldDropBody , {
1300+ const hookChangedOrigin = ! isSameOrigin ( state . url , hookUrl ) ;
1301+
1302+ if (
1303+ hookChangedOrigin
1304+ && ( statusCode === 301 || statusCode === 302 )
1305+ && updatedOptions . method === 'POST'
1306+ ) {
1307+ updatedOptions . method = 'GET' ;
1308+ this . _dropBody ( updatedOptions ) ;
1309+ }
1310+
1311+ if ( hookChangedOrigin ) {
1312+ if (
1313+ canRewrite
1314+ && this . _hasUnchangedBodyForRedirect ( updatedOptions , state , changedState )
1315+ ) {
1316+ this . _dropBody ( updatedOptions ) ;
1317+ }
1318+
1319+ this . _stripUnchangedCrossOriginState ( updatedOptions , hookUrl , {
12241320 ...state ,
12251321 changedState,
12261322 preserveUsername : hasExplicitCredentialInUrlChange ( changedState , hookUrl , 'username' )
@@ -1534,21 +1630,22 @@ export default class Request extends Duplex implements RequestEvents<Request> {
15341630 } else if ( is . asyncIterable ( body ) || ( is . iterable ( body ) && ! is . string ( body ) && ! isBuffer ( body ) ) ) {
15351631 ( async ( ) => {
15361632 const isInitialRequest = currentRequest === this ;
1633+ const bodyOptions = this . options ;
15371634
15381635 try {
15391636 for await ( const chunk of body ) {
1540- if ( this . options . body !== body ) {
1637+ if ( this . options !== bodyOptions || this . options . body !== body ) {
15411638 return ;
15421639 }
15431640
15441641 await this . _asyncWrite ( chunk , currentRequest ) ;
15451642
1546- if ( this . options . body !== body ) {
1643+ if ( this . options !== bodyOptions || this . options . body !== body ) {
15471644 return ;
15481645 }
15491646 }
15501647
1551- if ( this . options . body === body ) {
1648+ if ( this . options === bodyOptions && this . options . body === body ) {
15521649 if ( isInitialRequest ) {
15531650 super . end ( ) ;
15541651 return ;
@@ -1557,7 +1654,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
15571654 await this . _endWritableRequest ( currentRequest as ClientRequest ) ;
15581655 }
15591656 } catch ( error : unknown ) {
1560- if ( this . options . body !== body ) {
1657+ if ( this . options !== bodyOptions || this . options . body !== body ) {
15611658 return ;
15621659 }
15631660
@@ -1566,9 +1663,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
15661663 } ) ( ) ;
15671664 } else if ( is . undefined ( body ) ) {
15681665 // No body to send, end the request
1569- const cannotHaveBody = methodsWithoutBody . has ( this . options . method ) && ! ( this . options . method === 'GET' && this . options . allowGetBody ) ;
1570-
1571- if ( ( this . _noPipe ?? false ) || cannotHaveBody || currentRequest !== this ) {
1666+ if ( ( this . _noPipe ?? false ) || ! this . _methodCanHaveBody || currentRequest !== this ) {
15721667 currentRequest . end ( ) ;
15731668 }
15741669 } else {
@@ -1693,7 +1788,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
16931788 } ) ;
16941789 }
16951790
1696- private _stripCrossOriginState ( options : Options , urlToClear : URL , bodyAlreadyDropped : boolean ) {
1791+ private _stripCrossOriginState ( options : Options , urlToClear : URL ) {
16971792 for ( const header of crossOriginStripHeaders ) {
16981793 options . deleteInternalHeader ( header ) ;
16991794 }
@@ -1703,13 +1798,9 @@ export default class Request extends Duplex implements RequestEvents<Request> {
17031798
17041799 urlToClear . username = '' ;
17051800 urlToClear . password = '' ;
1706-
1707- if ( ! bodyAlreadyDropped ) {
1708- this . _dropBody ( options ) ;
1709- }
17101801 }
17111802
1712- private _stripUnchangedCrossOriginState ( options : Options , urlToClear : URL , bodyAlreadyDropped : boolean , state : CrossOriginState & {
1803+ private _stripUnchangedCrossOriginState ( options : Options , urlToClear : URL , state : CrossOriginState & {
17131804 changedState : Set < string > ;
17141805 preserveUsername : boolean ;
17151806 preservePassword : boolean ;
@@ -1731,16 +1822,26 @@ export default class Request extends Duplex implements RequestEvents<Request> {
17311822 options . password = '' ;
17321823 urlToClear . password = '' ;
17331824 }
1825+ }
17341826
1735- if (
1736- ! bodyAlreadyDropped
1737- && ! state . changedState . has ( 'body' )
1738- && ! state . changedState . has ( 'json' )
1739- && ! state . changedState . has ( 'form' )
1740- && isBodyUnchanged ( options , state )
1741- ) {
1742- this . _dropBody ( options ) ;
1743- }
1827+ private get _methodCanHaveBody ( ) : boolean {
1828+ return ! methodsWithoutBody . has ( this . options . method ) || ( this . options . method === 'GET' && this . options . allowGetBody ) ;
1829+ }
1830+
1831+ private _canWriteBody ( ) : boolean {
1832+ return ! this . _noPipe && ! this . isReadonly && this . _methodCanHaveBody ;
1833+ }
1834+
1835+ private _hasBodyForRedirect ( options : Options ) : boolean {
1836+ return ! is . undefined ( options . body ) || ! is . undefined ( options . json ) || ! is . undefined ( options . form ) || this . _hasWrittenBody || this . _hasWritableBody ;
1837+ }
1838+
1839+ private _hasUnchangedBodyForRedirect ( options : Options , state : CrossOriginState , changedState : Set < string > ) : boolean {
1840+ return ! changedState . has ( 'body' )
1841+ && ! changedState . has ( 'json' )
1842+ && ! changedState . has ( 'form' )
1843+ && this . _hasBodyForRedirect ( options )
1844+ && isBodyUnchanged ( options , state ) ;
17441845 }
17451846
17461847 private _dropBody ( updatedOptions : Options ) {
@@ -1749,13 +1850,29 @@ export default class Request extends Duplex implements RequestEvents<Request> {
17491850
17501851 this . options . clearBody ( ) ;
17511852
1853+ this . _destroyBody ( body ) ;
1854+
1855+ if ( ! hadOptionBody && ! this . writableEnded ) {
1856+ this . _skipRequestEndInFinal = true ;
1857+ super . end ( ) ;
1858+ }
1859+
1860+ updatedOptions . clearBody ( ) ;
1861+
1862+ this . _bodySize = undefined ;
1863+ this . _hasWrittenBody = false ;
1864+ this . _hasWritableBody = false ;
1865+ }
1866+
1867+ private _destroyBody ( body : unknown ) {
17521868 if ( is . nodeStream ( body ) ) {
1753- body . off ( 'error' , this . _onBodyError ) ;
1754- body . unpipe ( ) ;
1755- body . on ( 'error' , noop ) ;
1756- body . destroy ( ) ;
1869+ const bodyStream = body as NodeJS . ReadableStream & { destroy ( ) : void } ;
1870+ bodyStream . off ( 'error' , this . _onBodyError ) ;
1871+ bodyStream . unpipe ( ) ;
1872+ bodyStream . on ( 'error' , noop ) ;
1873+ bodyStream . destroy ( ) ;
17571874 } else if ( is . asyncIterable ( body ) || ( is . iterable ( body ) && ! is . string ( body ) && ! isBuffer ( body ) ) ) {
1758- const iterableBody = body as Iterator < unknown > | AsyncIterator < unknown > ;
1875+ const iterableBody = body as unknown as Iterator < unknown > | AsyncIterator < unknown > ;
17591876
17601877 // Signal the iterator to clean up, but don't await it:
17611878 // the for-await loop in _sendBody exits via the options.body sentinel,
@@ -1769,14 +1886,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
17691886 }
17701887 } catch { }
17711888 }
1772- } else if ( ! hadOptionBody && ! this . writableEnded ) {
1773- this . _skipRequestEndInFinal = true ;
1774- super . end ( ) ;
17751889 }
1776-
1777- updatedOptions . clearBody ( ) ;
1778-
1779- this . _bodySize = undefined ;
17801890 }
17811891
17821892 private readonly _onBodyError = ( error : Error ) : void => {
0 commit comments