Skip to content

Commit e45d023

Browse files
committed
Disable default request headers when proxying in modern Node (22.13+)
This is a breaking change as it changes the logic around message framing when overriding with a custom body. In the past, we attempted to fix the framing (i.e. setting a content-length header) any time you didn't specifically disable this. Now, we _always_ fix the framing if you modify a request or response body, to ensure it's always valid. This was somewhat handled in the past by the default request headers, but now that those are disabled the UX is very poor without this. This should not affect you, unless you're intentionally setting an invalid content-length or sending requests (not responses) with a body but no framing (in which case they're likely unparseable anyway). Normal valid requests will never be changed.
1 parent b44f180 commit e45d023

File tree

5 files changed

+114
-54
lines changed

5 files changed

+114
-54
lines changed

src/rules/passthrough-handling.ts

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -286,50 +286,94 @@ export function getH2HeadersAfterModification(
286286
};
287287
}
288288

289-
// Helper to handle content-length nicely for you when rewriting requests with callbacks
290-
export function getContentLengthAfterModification(
289+
// When modifying requests, we ensure you always have correct framing, as it's impossible
290+
// to send a request with framing that doesn't match the body.
291+
export function getRequestContentLengthAfterModification(
291292
body: string | Uint8Array | Buffer,
292293
originalHeaders: Headers | RawHeaders,
293294
replacementHeaders: Headers | RawHeaders | undefined,
294-
mismatchAllowed: boolean = false
295+
context: {
296+
httpVersion: 1 | 2
297+
// N.b. we ignore the method though - you can proxy requests that include a body
298+
// even if they really shouldn't, as long as it's plausibly parseable.
299+
}
295300
): string | undefined {
296301
// If there was a content-length header, it might now be wrong, and it's annoying
297302
// to need to set your own content-length override when you just want to change
298-
// the body. To help out, if you override the body but don't explicitly override
299-
// the (now invalid) content-length, then we fix it for you.
303+
// the body. To help out, if you override the body in a way that results in invalid
304+
// content-length headers, we fix them for you.
305+
306+
// For HTTP/2, framing is optional/advisory so we can just skip this entirely.
307+
if (context.httpVersion !== 1) return undefined;
308+
309+
const resultingHeaders = replacementHeaders || originalHeaders;
300310

301-
if (getHeaderValue(originalHeaders, 'content-length') === undefined) {
302-
// Nothing to override - use the replacement value, or undefined
303-
return getHeaderValue(replacementHeaders || {}, 'content-length');
311+
if (getHeaderValue(resultingHeaders, 'transfer-encoding')?.includes('chunked')) {
312+
return undefined; // No content-length header games needed
304313
}
305314

306-
if (!replacementHeaders) {
307-
// There was a length set, and you've provided a body but not changed it.
308-
// You probably just want to send this body and have it work correctly,
309-
// so we should fix the content length for you automatically.
310-
return byteLength(body).toString();
315+
const expectedLength = byteLength(body).toString();
316+
const contentLengthHeader = getHeaderValue(resultingHeaders, 'content-length');
317+
318+
if (contentLengthHeader === expectedLength) return undefined;
319+
if (contentLengthHeader === undefined) return expectedLength; // Differs from responses
320+
321+
// The content-length is expected, but it's wrong or missing.
322+
323+
// If there is a wrong content-length set, and it's not just leftover from the original headers (i.e.
324+
// you intentionally set it) then we show a warning since we're ignoring your (invalid) instructions.
325+
if (contentLengthHeader && contentLengthHeader !== getHeaderValue(originalHeaders, 'content-length')) {
326+
console.warn(`Invalid request content-length header was ignored - resetting from ${
327+
contentLengthHeader
328+
} to ${
329+
expectedLength
330+
}`);
311331
}
312332

313-
// There was a content length before, and you're replacing the headers entirely
314-
const lengthOverride = getHeaderValue(replacementHeaders, 'content-length')?.toString();
333+
return expectedLength;
334+
}
315335

316-
// If you're setting the content-length to the same as the origin headers, even
317-
// though that's the wrong value, it *might* be that you're just extending the
318-
// existing headers, and you're doing this by accident (we can't tell for sure).
319-
// We use invalid content-length as instructed, but print a warning just in case.
320-
if (
321-
lengthOverride === getHeaderValue(originalHeaders, 'content-length') &&
322-
lengthOverride !== byteLength(body).toString() &&
323-
!mismatchAllowed // Set for HEAD responses
324-
) {
325-
console.warn(oneLine`
326-
Passthrough modifications overrode the body and the content-length header
327-
with mismatched values, which may be a mistake. The body contains
328-
${byteLength(body)} bytes, whilst the header was set to ${lengthOverride}.
329-
`);
336+
// When modifying responses, we ensure you always have correct framing, but in a slightly more
337+
// relaxed way than for requests: we allow no framing and HEAD responses, we just block invalid values.
338+
export function getResponseContentLengthAfterModification(
339+
body: string | Uint8Array | Buffer,
340+
originalHeaders: Headers | RawHeaders,
341+
replacementHeaders: Headers | RawHeaders | undefined,
342+
context: {
343+
httpMethod: string
344+
httpVersion: 1 | 2
345+
}
346+
): string | undefined {
347+
// For HEAD requests etc, you can set an arbitrary content-length header regardless
348+
// of the empty body, so we don't bother checking anything. For HTTP/2, framing is
349+
// optional/advisory so we can just skip this entirely.
350+
if (context.httpVersion !== 1 || context.httpMethod === 'HEAD') return undefined;
351+
352+
const resultingHeaders = replacementHeaders || originalHeaders;
353+
354+
if (getHeaderValue(resultingHeaders, 'transfer-encoding')?.includes('chunked')) {
355+
return undefined; // No content-length header games needed
356+
}
357+
358+
const expectedLength = byteLength(body).toString();
359+
const contentLengthHeader = getHeaderValue(resultingHeaders, 'content-length');
360+
361+
if (contentLengthHeader === expectedLength) return undefined;
362+
if (contentLengthHeader === undefined) return undefined; // Differs from requests - we do allow this for responses
363+
364+
// The content-length is set, but it's wrong.
365+
366+
// If there is a wrong content-length set, and it's not just leftover from the original headers (i.e.
367+
// you intentionally set it) then we show a warning since we're ignoring your (invalid) instructions.
368+
if (contentLengthHeader && contentLengthHeader !== getHeaderValue(originalHeaders, 'content-length')) {
369+
console.warn(`Invalid response content-length header was ignored - resetting from ${
370+
contentLengthHeader
371+
} to ${
372+
expectedLength
373+
}`);
330374
}
331375

332-
return lengthOverride;
376+
return expectedLength;
333377
}
334378

335379
// Function to check if we should skip https errors for the current hostname and port,

src/rules/requests/request-handlers.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ import {
7474
PassThroughLookupOptions,
7575
} from '../passthrough-handling-definitions';
7676
import {
77-
getContentLengthAfterModification,
77+
getRequestContentLengthAfterModification,
78+
getResponseContentLengthAfterModification,
7879
getHostAfterModification,
7980
getH2HeadersAfterModification,
8081
MODIFIABLE_PSEUDOHEADERS,
@@ -556,19 +557,23 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
556557
}
557558
}
558559

559-
if (reqBodyOverride) {
560+
if (reqBodyOverride) { // Can't check framing without body changes, since we won't have the body yet
560561
// We always re-encode the body to match the resulting content-encoding header:
561562
reqBodyOverride = await encodeBodyBuffer(
562563
reqBodyOverride,
563564
rawHeaders
564565
);
565566

566-
const updatedCLHeader = getContentLengthAfterModification(
567+
const updatedCLHeader = getRequestContentLengthAfterModification(
567568
reqBodyOverride,
568569
clientReq.headers,
569-
(updateHeaders && getHeaderValue(updateHeaders, 'content-length') !== undefined)
570-
? rawHeaders // Iff you replaced the content length
571-
: replaceHeaders
570+
(updateHeaders && (
571+
getHeaderValue(updateHeaders, 'content-length') !== undefined ||
572+
getHeaderValue(updateHeaders, 'transfer-encoding')?.includes('chunked')
573+
))
574+
? rawHeaders // Iff you replaced the relevant headers
575+
: replaceHeaders,
576+
{ httpVersion: isH2Downstream ? 2 : 1 }
572577
);
573578

574579
if (updatedCLHeader !== undefined) {
@@ -637,12 +642,13 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
637642

638643
reqBodyOverride = await buildOverriddenBody(modifiedReq, headers);
639644

640-
if (reqBodyOverride) {
645+
if (reqBodyOverride || modifiedReq?.headers) {
641646
// Automatically match the content-length to the body, unless it was explicitly overriden.
642-
headers['content-length'] = getContentLengthAfterModification(
643-
reqBodyOverride,
647+
headers['content-length'] = getRequestContentLengthAfterModification(
648+
reqBodyOverride || completedRequest.body.buffer,
644649
clientHeaders,
645-
modifiedReq?.headers
650+
modifiedReq?.headers,
651+
{ httpVersion: isH2Downstream ? 2 : 1 }
646652
);
647653
}
648654

@@ -747,6 +753,7 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
747753
headers: shouldTryH2Upstream
748754
? rawHeadersToObjectPreservingCase(rawHeaders)
749755
: flattenPairedRawHeaders(rawHeaders) as any,
756+
setDefaultHeaders: shouldTryH2Upstream, // For now, we need this for unexpected H2->H1 header fallback
750757
lookup: getDnsLookupFunction(this.lookupOptions) as typeof dns.lookup,
751758
// ^ Cast required to handle __promisify__ type hack in the official Node types
752759
agent,
@@ -889,21 +896,21 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
889896
}
890897
}
891898

892-
if (resBodyOverride) {
899+
if (resBodyOverride) { // Can't check framing without body changes, since we won't have the body yet
893900
// In the above cases, the overriding data is assumed to always be in decoded form,
894901
// so we re-encode the body to match the resulting content-encoding header:
895902
resBodyOverride = await encodeBodyBuffer(
896903
resBodyOverride,
897904
serverRawHeaders
898905
);
899906

900-
const updatedCLHeader = getContentLengthAfterModification(
907+
const updatedCLHeader = getResponseContentLengthAfterModification(
901908
resBodyOverride,
902909
serverRes.headers,
903910
(updateHeaders && getHeaderValue(updateHeaders, 'content-length') !== undefined)
904911
? serverRawHeaders // Iff you replaced the content length
905912
: replaceHeaders,
906-
method === 'HEAD' // HEAD responses are allowed mismatched content-length
913+
{ httpMethod: method, httpVersion: serverRes.httpVersion.startsWith('1.') ? 1 : 2 }
907914
);
908915

909916
if (updatedCLHeader !== undefined) {
@@ -980,13 +987,20 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
980987

981988
resBodyOverride = await buildOverriddenBody(modifiedRes, serverHeaders);
982989

983-
if (resBodyOverride) {
984-
serverHeaders['content-length'] = getContentLengthAfterModification(
985-
resBodyOverride,
990+
if (resBodyOverride || modifiedRes?.headers) {
991+
const updatedContentLength = getResponseContentLengthAfterModification(
992+
resBodyOverride || originalBody,
986993
serverRes.headers,
987994
modifiedRes?.headers,
988-
method === 'HEAD' // HEAD responses are allowed mismatched content-length
995+
{
996+
httpMethod: method,
997+
httpVersion: serverRes.httpVersion.startsWith('1.') ? 1 : 2
998+
}
989999
);
1000+
1001+
if (updatedContentLength !== undefined) {
1002+
serverHeaders['content-length'] = updatedContentLength;
1003+
}
9901004
}
9911005

9921006
serverRawHeaders = objectHeadersToRaw(serverHeaders);

test/integration/proxying/proxy-transforms.spec.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import _ = require("lodash");
22
import * as path from 'path';
33
import * as http from 'http';
4+
import * as zlib from 'zlib';
45

56
import request = require("request-promise-native");
6-
import * as zlib from 'zlib';
77

88
import { getLocal, Mockttp } from "../../..";
99
import {
1010
expect,
1111
nodeOnly,
12-
defaultNodeConnectionHeader
12+
defaultNodeConnectionHeader,
13+
nodeSatisfies,
14+
DEFAULT_REQ_HEADERS_DISABLED
1315
} from "../../test-utils";
1416
import { streamToBuffer } from "../../../src/util/buffer-utils";
1517

@@ -310,6 +312,7 @@ nodeOnly(() => {
310312
await server.forAnyRequest().thenPassThrough({
311313
transformRequest: {
312314
replaceHeaders: {
315+
'transfer-encoding': 'chunked', // Required for body
313316
'custom-header': 'replaced-value'
314317
}
315318
}
@@ -324,11 +327,10 @@ nodeOnly(() => {
324327
expect(response.url).to.equal(`http://localhost:${remoteServer.port}/abc`); // From tunnel, even without the host header
325328
expect(response.method).to.equal('POST');
326329
expect(response.headers).to.deep.equal({
327-
// Default Node headers:
328-
'connection': defaultNodeConnectionHeader,
330+
...(!nodeSatisfies(DEFAULT_REQ_HEADERS_DISABLED)
331+
? { 'connection': defaultNodeConnectionHeader }
332+
: {}),
329333
'transfer-encoding': 'chunked',
330-
331-
// No other headers, only injected value:
332334
'custom-header': 'replaced-value'
333335
});
334336
expect(response.body).to.equal(JSON.stringify({ a: 1 }));

test/integration/remote-client.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,6 @@ nodeOnly(() => {
254254
await remoteServer.forPost(targetServer.urlFor('/res')).thenPassThrough({
255255
transformResponse: {
256256
updateHeaders: {
257-
'custom-header': undefined, // Remove
258257
'injected-header': 'injected-value' // Add
259258
},
260259
updateJsonBody: {

test/test-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ export const BROKEN_H1_OVER_H2_TUNNELLING = "^18.8";
308308
export const DEFAULT_KEEP_ALIVE = ">=19";
309309
export const FIXED_KEEP_ALIVE_BEHAVIOUR = ">=20";
310310
export const BROKEN_H2_OVER_H2_TUNNELLING = "~20.12"; // https://github.com/nodejs/node/issues/52344
311+
export const DEFAULT_REQ_HEADERS_DISABLED = "^22.13.0 || >=23.5.0";
311312

312313
export const defaultNodeConnectionHeader = nodeSatisfies(DEFAULT_KEEP_ALIVE)
313314
? 'keep-alive'

0 commit comments

Comments
 (0)