Skip to content

Commit 2d2630f

Browse files
committed
Try harder to preserve raw header formatting with passthrough transforms
We now preserve more-or-less everything for every header that you don't directly modify. This should affect header dupe handling, header key casing and maybe ordering, but nothing else, so shouldn't cause any meaningful effects in well-behaved scenarios.
1 parent da93d64 commit 2d2630f

File tree

6 files changed

+220
-59
lines changed

6 files changed

+220
-59
lines changed

Diff for: src/rules/passthrough-handling.ts

+9-8
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { oneLine } from 'common-tags';
66
import CacheableLookup from 'cacheable-lookup';
77
import * as semver from 'semver';
88

9-
import { CompletedBody, Headers } from '../types';
9+
import { CompletedBody, Headers, RawHeaders } from '../types';
1010
import { byteLength } from '../util/util';
1111
import { asBuffer } from '../util/buffer-utils';
1212
import { isLocalhostAddress, normalizeIP } from '../util/socket-util';
1313
import { CachedDns, dnsLookup, DnsLookupFunction } from '../util/dns';
1414
import { isMockttpBody, encodeBodyBuffer } from '../util/request-utils';
1515
import { areFFDHECurvesSupported } from '../util/openssl-compat';
16+
import { ErrorLike } from '../util/error';
17+
import { getHeaderValue } from '../util/header-utils';
1618

1719
import {
1820
CallbackRequestResult,
@@ -23,7 +25,6 @@ import {
2325
CADefinition,
2426
PassThroughLookupOptions
2527
} from './passthrough-handling-definitions';
26-
import { ErrorLike } from '../util/error';
2728

2829
// TLS settings for proxied connections, intended to avoid TLS fingerprint blocking
2930
// issues so far as possible, by closely emulating a Firefox Client Hello:
@@ -266,18 +267,18 @@ export function getH2HeadersAfterModification(
266267
// Helper to handle content-length nicely for you when rewriting requests with callbacks
267268
export function getContentLengthAfterModification(
268269
body: string | Uint8Array | Buffer,
269-
originalHeaders: Headers,
270-
replacementHeaders: Headers | undefined,
270+
originalHeaders: Headers | RawHeaders,
271+
replacementHeaders: Headers | RawHeaders | undefined,
271272
mismatchAllowed: boolean = false
272273
): string | undefined {
273274
// If there was a content-length header, it might now be wrong, and it's annoying
274275
// to need to set your own content-length override when you just want to change
275276
// the body. To help out, if you override the body but don't explicitly override
276277
// the (now invalid) content-length, then we fix it for you.
277278

278-
if (!_.has(originalHeaders, 'content-length')) {
279+
if (getHeaderValue(originalHeaders, 'content-length') === undefined) {
279280
// Nothing to override - use the replacement value, or undefined
280-
return (replacementHeaders || {})['content-length'];
281+
return getHeaderValue(replacementHeaders || {}, 'content-length');
281282
}
282283

283284
if (!replacementHeaders) {
@@ -288,14 +289,14 @@ export function getContentLengthAfterModification(
288289
}
289290

290291
// There was a content length before, and you're replacing the headers entirely
291-
const lengthOverride = replacementHeaders['content-length']?.toString();
292+
const lengthOverride = getHeaderValue(replacementHeaders, 'content-length')?.toString();
292293

293294
// If you're setting the content-length to the same as the origin headers, even
294295
// though that's the wrong value, it *might* be that you're just extending the
295296
// existing headers, and you're doing this by accident (we can't tell for sure).
296297
// We use invalid content-length as instructed, but print a warning just in case.
297298
if (
298-
lengthOverride === originalHeaders['content-length'] &&
299+
lengthOverride === getHeaderValue(originalHeaders, 'content-length') &&
299300
lengthOverride !== byteLength(body).toString() &&
300301
!mismatchAllowed // Set for HEAD responses
301302
) {

Diff for: src/rules/requests/request-handlers.ts

+26-40
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ import {
4040
pairFlatRawHeaders,
4141
findRawHeaderIndex,
4242
dropDefaultHeaders,
43-
validateHeader
43+
validateHeader,
44+
updateRawHeaders,
45+
getHeaderValue
4446
} from '../../util/header-utils';
4547
import { streamToBuffer, asBuffer } from '../../util/buffer-utils';
4648
import {
@@ -484,8 +486,6 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
484486
let headersManuallyModified = false;
485487

486488
if (this.transformRequest) {
487-
let headers = rawHeadersToObject(rawHeaders);
488-
489489
const {
490490
replaceMethod,
491491
updateHeaders,
@@ -502,14 +502,9 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
502502
}
503503

504504
if (updateHeaders) {
505-
headers = {
506-
...headers,
507-
...updateHeaders
508-
};
509-
headersManuallyModified = true;
505+
rawHeaders = updateRawHeaders(rawHeaders, updateHeaders);
510506
} else if (replaceHeaders) {
511-
headers = { ...replaceHeaders };
512-
headersManuallyModified = true;
507+
rawHeaders = objectHeadersToRaw(replaceHeaders);
513508
}
514509

515510
if (replaceBody) {
@@ -564,22 +559,22 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
564559
// We always re-encode the body to match the resulting content-encoding header:
565560
reqBodyOverride = await encodeBodyBuffer(
566561
reqBodyOverride,
567-
headers
562+
rawHeaders
568563
);
569564

570-
headers['content-length'] = getContentLengthAfterModification(
565+
const updatedCLHeader = getContentLengthAfterModification(
571566
reqBodyOverride,
572567
clientReq.headers,
573-
(updateHeaders && updateHeaders['content-length'] !== undefined)
574-
? headers // Iff you replaced the content length
575-
: replaceHeaders,
568+
(updateHeaders && getHeaderValue(updateHeaders, 'content-length') !== undefined)
569+
? rawHeaders // Iff you replaced the content length
570+
: replaceHeaders
576571
);
577-
}
578572

579-
if (headersManuallyModified || reqBodyOverride) {
580-
// If the headers have been updated (implicitly or explicitly) we need to regenerate them. We avoid
581-
// this if possible, because it normalizes headers, which is slightly lossy (e.g. they're lowercased).
582-
rawHeaders = objectHeadersToRaw(headers);
573+
if (updatedCLHeader !== undefined) {
574+
rawHeaders = updateRawHeaders(rawHeaders, {
575+
'content-length': updatedCLHeader
576+
});
577+
}
583578
}
584579
} else if (this.beforeRequest) {
585580
const clientRawHeaders = rawHeaders;
@@ -827,9 +822,6 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
827822
}
828823

829824
if (this.transformResponse) {
830-
let responseHeadersModified = false;
831-
let serverHeaders = rawHeadersToObject(serverRawHeaders);
832-
833825
const {
834826
replaceStatus,
835827
updateHeaders,
@@ -847,14 +839,9 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
847839
}
848840

849841
if (updateHeaders) {
850-
serverHeaders = {
851-
...serverHeaders,
852-
...updateHeaders
853-
};
854-
responseHeadersModified = true;
842+
serverRawHeaders = updateRawHeaders(serverRawHeaders, updateHeaders);
855843
} else if (replaceHeaders) {
856-
serverHeaders = { ...replaceHeaders };
857-
responseHeadersModified = true;
844+
serverRawHeaders = objectHeadersToRaw(replaceHeaders);
858845
}
859846

860847
if (replaceBody) {
@@ -915,24 +902,23 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
915902
// so we re-encode the body to match the resulting content-encoding header:
916903
resBodyOverride = await encodeBodyBuffer(
917904
resBodyOverride,
918-
serverHeaders
905+
serverRawHeaders
919906
);
920907

921-
serverHeaders['content-length'] = getContentLengthAfterModification(
908+
const updatedCLHeader = getContentLengthAfterModification(
922909
resBodyOverride,
923910
serverRes.headers,
924-
(updateHeaders && updateHeaders['content-length'] !== undefined)
925-
? serverHeaders // Iff you replaced the content length
911+
(updateHeaders && getHeaderValue(updateHeaders, 'content-length') !== undefined)
912+
? serverRawHeaders // Iff you replaced the content length
926913
: replaceHeaders,
927914
method === 'HEAD' // HEAD responses are allowed mismatched content-length
928915
);
929-
responseHeadersModified = true;
930-
}
931916

932-
if (responseHeadersModified) {
933-
// If the headers have been updated (implicitly or explicitly) we need to regenerate them. We avoid
934-
// this if possible, because it normalizes headers, which is slightly lossy (e.g. they're lowercased).
935-
serverRawHeaders = objectHeadersToRaw(serverHeaders);
917+
if (updatedCLHeader !== undefined) {
918+
serverRawHeaders = updateRawHeaders(serverRawHeaders, {
919+
'content-length': updatedCLHeader
920+
});
921+
}
936922
}
937923
} else if (this.beforeResponse) {
938924
let modifiedRes: CallbackResponseResult | void;

Diff for: src/util/header-utils.ts

+87
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as _ from 'lodash';
12
import * as http from 'http';
23

34
import {
@@ -27,6 +28,19 @@ with. Those are:
2728
export const findRawHeader = (rawHeaders: RawHeaders, targetKey: string) =>
2829
rawHeaders.find(([key]) => key.toLowerCase() === targetKey);
2930

31+
export const getHeaderValue = (headers: Headers | RawHeaders, targetKey: Lowercase<string>) => {
32+
if (Array.isArray(headers)) {
33+
return findRawHeader(headers, targetKey)?.[1];
34+
} else {
35+
const value = headers[targetKey];
36+
if (Array.isArray(value)) {
37+
return value[0];
38+
} else {
39+
return value;
40+
}
41+
}
42+
};
43+
3044
export const findRawHeaderIndex = (rawHeaders: RawHeaders, targetKey: string) =>
3145
rawHeaders.findIndex(([key]) => key.toLowerCase() === targetKey);
3246

@@ -158,6 +172,50 @@ export function objectHeadersToFlat(headers: Headers): string[] {
158172
return flatHeaders;
159173
}
160174

175+
/**
176+
* Combine the given headers with the raw headers, preserving the raw header details where
177+
* possible. Headers keys that exist in the raw headers (case insensitive) will be overridden,
178+
* while undefined header values will remove the header from the raw headers entirely.
179+
*
180+
* When proxying we often have raw received headers that we want to forward upstream exactly
181+
* as they were received, but we also want to add or modify a subset of those headers. This
182+
* method carefully does that - preserving everything that isn't actively modified as-is.
183+
*/
184+
export function updateRawHeaders(
185+
rawHeaders: RawHeaders,
186+
headers: Headers
187+
) {
188+
const updatedRawHeaders = [...rawHeaders];
189+
const rawHeaderKeys = updatedRawHeaders.map(([key]) => key.toLowerCase());
190+
191+
for (const key of Object.keys(headers)) {
192+
const lowerCaseKey = key.toLowerCase();
193+
for (let i = 0; i < rawHeaderKeys.length; i++) {
194+
// If you insert a header that already existed, remove all previous values
195+
if (rawHeaderKeys[i] === lowerCaseKey) {
196+
updatedRawHeaders.splice(i, 1);
197+
rawHeaderKeys.splice(i, 1);
198+
}
199+
}
200+
}
201+
202+
// We do all removals in advance, then do all additions here, to ensure that adding
203+
// a new header twice works correctly.
204+
205+
for (const [key, value] of Object.entries(headers)) {
206+
// Skip (effectively delete) undefined/null values from the headers
207+
if (value === undefined || value === null) continue;
208+
209+
if (Array.isArray(value)) {
210+
value.forEach((v) => updatedRawHeaders.push([key, v]));
211+
} else {
212+
updatedRawHeaders.push([key, value]);
213+
}
214+
}
215+
216+
return updatedRawHeaders;
217+
}
218+
161219
// See https://httptoolkit.com/blog/translating-http-2-into-http-1/ for details on the
162220
// transformations required between H2 & H1 when proxying.
163221
export function h2HeadersToH1(h2Headers: RawHeaders): RawHeaders {
@@ -217,4 +275,33 @@ export function validateHeader(name: string, value: string | string[]): boolean
217275
} catch (e) {
218276
return false;
219277
}
278+
}
279+
280+
/**
281+
* Set the value of a given header, overwriting it if present or otherwise adding it as a new header.
282+
*
283+
* For header objects, this overwrites all values. For raw headers, this overwrites the last value, so
284+
* if multiple values are present others may remain. In general you probably don't want to use this
285+
* for headers that could legally have multiple values present.
286+
*/
287+
export const setHeaderValue = (
288+
headers: Headers | RawHeaders,
289+
headerKey: string,
290+
headerValue: string,
291+
options: { prepend?: true } = {}
292+
) => {
293+
const lowercaseHeaderKey = headerKey.toLowerCase();
294+
295+
if (Array.isArray(headers)) {
296+
const headerPair = _.findLast(headers, ([key]) => key.toLowerCase() === lowercaseHeaderKey);
297+
if (headerPair) {
298+
headerPair[1] = headerValue;
299+
} else {
300+
if (options.prepend) headers.unshift([headerKey, headerValue]);
301+
else headers.push([headerKey, headerValue]);
302+
}
303+
} else {
304+
const existingKey = Object.keys(headers).find(k => k.toLowerCase() === lowercaseHeaderKey);
305+
headers[existingKey || headerKey] = headerValue;
306+
}
220307
}

Diff for: src/util/request-utils.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
} from './buffer-utils';
3434
import {
3535
flattenPairedRawHeaders,
36+
getHeaderValue,
3637
objectHeadersToFlat,
3738
objectHeadersToRaw,
3839
pairFlatRawHeaders,
@@ -89,8 +90,8 @@ export function isHttp2(
8990
('stream' in message && 'createPushResponse' in message); // H2 response
9091
}
9192

92-
export async function encodeBodyBuffer(buffer: Uint8Array, headers: Headers) {
93-
const contentEncoding = headers['content-encoding'];
93+
export async function encodeBodyBuffer(buffer: Uint8Array, headers: Headers | RawHeaders) {
94+
const contentEncoding = getHeaderValue(headers, 'content-encoding');
9495

9596
// We skip encodeBuffer entirely if possible - this isn't strictly necessary, but it's useful
9697
// so you can drop the http-encoding package in bundling downstream without issue in cases

0 commit comments

Comments
 (0)