Skip to content

Commit b1e8c34

Browse files
committed
Make forwarding option a request transform, add path & query transforms
This is a breaking change if you're using thenForwardTo with a beforeRequest callback (no longer allowed: return a url from beforeRequest instead), or if you're manually building rules with a 'forwarding' option (use transformRequest.replaceHost instead). In most cases though, this should work as before, but now supports transformation of the path & query, match/replace transformation of the host itself, and simplifies & aligns forwarding with other transform options generally.
1 parent ca65c67 commit b1e8c34

17 files changed

+503
-324
lines changed

src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ export type {
5555
CADefinition,
5656
ForwardingOptions,
5757
PassThroughLookupOptions,
58-
PassThroughStepConnectionOptions
58+
PassThroughStepConnectionOptions,
59+
PassThroughInitialTransforms
5960
} from './rules/passthrough-handling-definitions';
61+
export type { MatchReplacePairs } from './rules/match-replace';
6062

6163
export type { RequestRuleBuilder } from "./rules/requests/request-rule-builder";
6264
export type { WebSocketRuleBuilder } from "./rules/websockets/websocket-rule-builder";

src/rules/match-replace.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* An array of match/replace pairs. These will be applied to the initial value
3+
* like `input.replace(p1, p2)`, applied in the order provided. The first parameter
4+
* can be either a string or RegExp to match, and the second must be a string to
5+
* insert. The normal `str.replace` $ placeholders can be used in the second
6+
* argument, so that e.g. $1 will insert the 1st matched group.
7+
*/
8+
export type MatchReplacePairs = Array<[string | RegExp, string]>;
9+
10+
export function applyMatchReplace(input: string, matchReplace: MatchReplacePairs): string {
11+
let result = input;
12+
for (const [match, replacement] of matchReplace) {
13+
result = result.replace(match, replacement);
14+
}
15+
return result;
16+
}
17+
18+
export type SerializedRegex = { regexSource: string, flags: string };
19+
20+
export const serializeRegex = (regex: RegExp): SerializedRegex => ({ regexSource: regex.source, flags: regex.flags });
21+
export const deserializeRegex = (regex: SerializedRegex) => new RegExp(regex.regexSource, regex.flags);
22+
23+
export type SerializedMatchReplacePairs = Array<[SerializedRegex | string, string]>;
24+
25+
export const serializeMatchReplaceConfiguration = (matchReplace: MatchReplacePairs): SerializedMatchReplacePairs =>
26+
matchReplace.map(([match, result]) => [
27+
match instanceof RegExp ? serializeRegex(match) : match,
28+
result
29+
]);
30+
31+
export const deserializeMatchReplaceConfiguration = (matchReplace: SerializedMatchReplacePairs): MatchReplacePairs =>
32+
matchReplace.map(([match, result]) => [
33+
typeof match !== 'string' && 'regexSource' in match
34+
? deserializeRegex(match)
35+
: match,
36+
result
37+
]);

src/rules/passthrough-handling-definitions.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ProxyConfig } from "./proxy-config";
2+
import { MatchReplacePairs } from "./match-replace";
23

34
export interface ForwardingOptions {
45
targetHost: string,
@@ -38,13 +39,10 @@ export type CADefinition =
3839
*/
3940
export interface PassThroughStepConnectionOptions {
4041
/**
41-
* The forwarding configuration for the passthrough rule.
42-
* This generally shouldn't be used explicitly unless you're
43-
* building rule data by hand. Instead, call `thenPassThrough`
44-
* to send data directly or `thenForwardTo` with options to
45-
* configure traffic forwarding.
42+
* A set of data to automatically transform a request. This includes properties
43+
* to support many transformation common use cases.
4644
*/
47-
forwarding?: ForwardingOptions,
45+
transformRequest?: PassThroughInitialTransforms;
4846

4947
/**
5048
* A list of hostnames for which server certificate and TLS version errors
@@ -121,4 +119,49 @@ export interface PassThroughStepConnectionOptions {
121119
* transparently proxy network traffic, errors and all.
122120
*/
123121
simulateConnectionErrors?: boolean;
122+
}
123+
124+
/**
125+
* This defines the request transforms that we support for all passed through
126+
* requests (both HTTP and WebSockets).
127+
*/
128+
export interface PassThroughInitialTransforms {
129+
130+
// Made more specific in subclass overrides
131+
setProtocol?: 'http' | 'https' | 'ws' | 'wss';
132+
133+
/**
134+
* Replace the request host with a single fixed value, effectively forwarding
135+
* all requests to a different hostname.
136+
*
137+
* This cannot be combined with matchReplaceHost.
138+
*
139+
* If updateHostHeader is true, the Host (or :authority for HTTP/2+) header
140+
* will be updated automatically to match. If updateHostHeader is a string,
141+
* that will be used directly as the header value. If it's false no change
142+
* will be made. If not specified this defaults to true.
143+
*/
144+
replaceHost?: { targetHost: string, updateHostHeader?: true | false | string };
145+
146+
/**
147+
* Perform a series of string match & replace operations on the request host.
148+
*
149+
* This cannot be combined with replaceHost.
150+
*
151+
* If updateHostHeader is true, the Host (or :authority for HTTP/2+) header
152+
* will be updated automatically to match. If updateHostHeader is a string,
153+
* that will be used directly as the header value. If it's false no change
154+
* will be made. If not specified this defaults to true.
155+
*/
156+
matchReplaceHost?: { replacements: MatchReplacePairs, updateHostHeader?: true | false | string };
157+
158+
/**
159+
* Perform a series of string match & replace operations on the request path.
160+
*/
161+
matchReplacePath?: MatchReplacePairs;
162+
163+
/**
164+
* Perform a series of string match & replace operations on the request query string.
165+
*/
166+
matchReplaceQuery?: MatchReplacePairs;
124167
}

src/rules/passthrough-handling.ts

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { CachedDns, dnsLookup, DnsLookupFunction } from '../util/dns';
1414
import { isMockttpBody, encodeBodyBuffer } from '../util/request-utils';
1515
import { areFFDHECurvesSupported } from '../util/openssl-compat';
1616
import { ErrorLike, unreachableCheck } from '@httptoolkit/util';
17-
import { getHeaderValue } from '../util/header-utils';
17+
import { findRawHeaderIndex, getHeaderValue } from '../util/header-utils';
1818

1919
import {
2020
CallbackRequestResult,
@@ -23,8 +23,11 @@ import {
2323
import { AbortError } from './requests/request-step-impls';
2424
import {
2525
CADefinition,
26+
PassThroughInitialTransforms,
2627
PassThroughLookupOptions
2728
} from './passthrough-handling-definitions';
29+
import { getDefaultPort } from '../util/url';
30+
import { applyMatchReplace } from './match-replace';
2831

2932
// TLS settings for proxied connections, intended to avoid TLS fingerprint blocking
3033
// issues so far as possible, by closely emulating a Firefox Client Hello:
@@ -232,6 +235,91 @@ function deriveUrlLinkedHeader(
232235
return expectedValue;
233236
}
234237

238+
export function applyDestinationTransforms(
239+
transform: PassThroughInitialTransforms,
240+
{ isH2Downstream, rawHeaders, port, protocol, hostname, pathname, query }: {
241+
isH2Downstream: boolean,
242+
rawHeaders: RawHeaders,
243+
port: string | null
244+
protocol: string | null,
245+
hostname: string,
246+
pathname: string | null
247+
query: string | null
248+
},
249+
) {
250+
const {
251+
setProtocol,
252+
replaceHost,
253+
matchReplaceHost,
254+
matchReplacePath,
255+
matchReplaceQuery,
256+
} = transform;
257+
258+
if (setProtocol) {
259+
const wasDefaultPort = port === null || getDefaultPort(protocol || 'http') === parseInt(port, 10);
260+
protocol = setProtocol + ':';
261+
262+
// If we were on the default port, update that accordingly:
263+
if (wasDefaultPort) {
264+
port = getDefaultPort(protocol).toString();
265+
}
266+
}
267+
268+
if (replaceHost) {
269+
const { targetHost } = replaceHost;
270+
[hostname, port] = targetHost.split(':');
271+
}
272+
273+
if (matchReplaceHost) {
274+
const result = applyMatchReplace(port === null ? hostname! : `${hostname}:${port}`, matchReplaceHost.replacements);
275+
[hostname, port] = result.split(':');
276+
}
277+
278+
if ((replaceHost?.updateHostHeader ?? matchReplaceHost?.updateHostHeader) !== false) {
279+
const updateHostHeader = replaceHost?.updateHostHeader ?? matchReplaceHost?.updateHostHeader;
280+
const hostHeaderName = isH2Downstream ? ':authority' : 'host';
281+
282+
let hostHeaderIndex = findRawHeaderIndex(rawHeaders, hostHeaderName);
283+
let hostHeader: [string, string];
284+
285+
if (hostHeaderIndex === -1) {
286+
// Should never happen really, but just in case:
287+
hostHeader = [hostHeaderName, hostname!];
288+
hostHeaderIndex = rawHeaders.length;
289+
} else {
290+
// Clone this - we don't want to modify the original headers, as they're used for events
291+
hostHeader = _.clone(rawHeaders[hostHeaderIndex]);
292+
}
293+
rawHeaders[hostHeaderIndex] = hostHeader;
294+
295+
if (updateHostHeader === undefined || updateHostHeader === true) {
296+
// If updateHostHeader is true, or just not specified, match the new target
297+
hostHeader[1] = hostname + (port ? `:${port}` : '');
298+
} else if (updateHostHeader) {
299+
// If it's an explicit custom value, use that directly.
300+
hostHeader[1] = updateHostHeader;
301+
} // Otherwise: falsey means don't touch it.
302+
}
303+
304+
if (matchReplacePath) {
305+
pathname = applyMatchReplace(pathname || '/', matchReplacePath);
306+
}
307+
308+
if (matchReplaceQuery) {
309+
query = applyMatchReplace(query || '', matchReplaceQuery);
310+
}
311+
312+
return {
313+
reqUrl: new URL(`${protocol}//${hostname}${(port ? `:${port}` : '')}${pathname || '/'}${query || ''}`).toString(),
314+
protocol,
315+
hostname,
316+
port,
317+
pathname,
318+
query,
319+
rawHeaders
320+
};
321+
}
322+
235323
/**
236324
* Autocorrect the host header only in the case that if you didn't explicitly
237325
* override it yourself for some reason (e.g. if you're testing bad behaviour).
@@ -421,11 +509,11 @@ export const getDnsLookupFunction = _.memoize((lookupOptions: PassThroughLookupO
421509
});
422510

423511
export async function getClientRelativeHostname(
424-
hostname: string | null,
512+
hostname: string,
425513
remoteIp: string | undefined,
426514
lookupFn: DnsLookupFunction
427515
) {
428-
if (!hostname || !remoteIp || isLocalhostAddress(remoteIp)) return hostname;
516+
if (!remoteIp || isLocalhostAddress(remoteIp)) return hostname;
429517

430518
// Otherwise, we have a request from a different machine (or Docker container/VM/etc) and we need
431519
// to make sure that 'localhost' means _that_ machine, not ourselves.
@@ -441,7 +529,7 @@ export async function getClientRelativeHostname(
441529
// effectively free. We ignore errors to delegate unresolvable etc to request processing later.
442530
isLocalhostAddress(await dnsLookup(lookupFn, hostname).catch(() => null))
443531
) {
444-
return normalizeIP(remoteIp) as string | null;
532+
return normalizeIP(remoteIp);
445533

446534
// Note that we just redirect - we don't update the host header. From the POV of the target, it's still
447535
// 'localhost' traffic that should appear identical to normal.

src/rules/requests/request-rule-builder.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { merge, isString, isBuffer } from "lodash";
22
import { Readable } from "stream";
3+
import * as url from 'url';
34
import { MaybePromise } from '@httptoolkit/util';
45

56
import { Headers, CompletedRequest, Method, MockedEndpoint, Trailers } from "../../types";
@@ -379,16 +380,20 @@ export class RequestRuleBuilder extends BaseRuleBuilder {
379380
* @category Responses
380381
*/
381382
async thenForwardTo(
382-
forwardToLocation: string,
383-
options: Omit<PassThroughStepOptions, 'forwarding'> & {
384-
forwarding?: Omit<PassThroughStepOptions['forwarding'], 'targetHost'>
385-
} = {}
383+
target: string,
384+
options: PassThroughStepOptions = {}
386385
): Promise<MockedEndpoint> {
386+
const protocolIndex = target.indexOf('://');
387+
let { protocol, host } = protocolIndex !== -1
388+
? { protocol: target.slice(0, protocolIndex), host: target.slice(protocolIndex + 3) }
389+
: { host: target, protocol: null};
390+
387391
this.steps.push(new PassThroughStep({
388392
...options,
389-
forwarding: {
390-
...options.forwarding,
391-
targetHost: forwardToLocation
393+
transformRequest: {
394+
...options.transformRequest,
395+
setProtocol: protocol as 'http' | 'https' | undefined,
396+
replaceHost: { targetHost: host }
392397
}
393398
}));
394399

0 commit comments

Comments
 (0)