Skip to content

Commit aee9249

Browse files
authored
Fix: Preserve request body on cross-origin 307 and 308 redirects (#2460)
1 parent 28c0ca3 commit aee9249

4 files changed

Lines changed: 1146 additions & 373 deletions

File tree

documentation/2-options.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -772,7 +772,9 @@ Optionally, pass a function to dynamically decide based on the response object.
772772
#### **Note:**
773773
> - If a `303` is sent by the server in response to any request type (POST, DELETE, etc.), Got will request the resource pointed to in the location header via GET.\
774774
> This is in accordance with the [specification](https://tools.ietf.org/html/rfc7231#section-6.4.4). You can optionally turn on this behavior also for other redirect codes - see [`methodRewriting`](#methodrewriting).
775-
> - On cross-origin redirects, Got strips `host`, `cookie`, `cookie2`, `authorization`, and `proxy-authorization`.
775+
> - On cross-origin redirects, Got strips `host`, `cookie`, `cookie2`, `authorization`, and `proxy-authorization`, as well as any credentials embedded in the URL.
776+
> - `307` and `308` redirects preserve the request method and replayable body, even across origins, as required by the specification. Got fails instead of following when the body cannot be replayed.
777+
> - Other cross-origin redirects can drop the request body and request body headers to avoid forwarding payloads to another origin.
776778
> - When a redirect rewrites the request to `GET`, Got also strips request body headers.
777779
> - Use [`hooks.beforeRedirect`](9-hooks.md#beforeredirect) for app-specific sensitive headers.
778780

source/core/index.ts

Lines changed: 158 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import calculateRetryDelay from './calculate-retry-delay.js';
2727
import 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

192192
const 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+
194217
const 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

Comments
 (0)