Skip to content

Commit 2222a1c

Browse files
committed
Add mirrorTlsFingerprint option to passthrough rules
1 parent 5d7c764 commit 2222a1c

10 files changed

Lines changed: 458 additions & 43 deletions

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,5 +204,8 @@
204204
"socks-proxy-agent": "^8.0.5",
205205
"urlpattern-polyfill": "^10.1.0",
206206
"ws": "^8.20.0"
207+
},
208+
"optionalDependencies": {
209+
"tls-impersonate": "^0.1.0"
207210
}
208211
}

src/rules/passthrough-handling-definitions.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ export interface PassThroughStepConnectionOptions {
121121
* transparently proxy network traffic, errors and all.
122122
*/
123123
simulateConnectionErrors?: boolean;
124+
125+
/**
126+
* Mirror the inbound connection's TLS fingerprint into the upstream connection, so
127+
* that the proxied request presents the same TLS ClientHello (cipher suites, extensions,
128+
* curves, etc) as the original client, instead of Mockttp's own default fingerprint.
129+
*
130+
* This only applies to intercepted HTTPS/TLS traffic (where we have a client hello to
131+
* mirror), and requires the native `tls-impersonate` module - fully effective on Node
132+
* v26.4+. Where impersonation isn't available, this falls back to the default fingerprint.
133+
*
134+
* Defaults to false.
135+
*/
136+
mirrorTlsFingerprint?: boolean;
124137
}
125138

126139
/**

src/rules/passthrough-handling.ts

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import { isMockttpBody, encodeBodyBuffer } from '../util/request-utils';
1919
import { areFFDHECurvesSupported } from '../util/openssl-compat';
2020
import { findRawHeaderIndex, getHeaderValue } from '../util/header-utils';
2121
import { getDefaultPort } from '../util/url';
22-
import { TlsMetadata } from '../util/socket-extensions';
22+
import { TlsMetadata, TlsClientHello } from '../util/socket-extensions';
23+
import { buildTlsImpersonationConfig } from '../util/tls-impersonation';
24+
import type { Connection } from './http-agents';
2325

2426
import {
2527
CallbackRequestResult,
@@ -37,6 +39,11 @@ import { applyMatchReplace } from './match-replace';
3739
// issues so far as possible, by closely emulating a Firefox Client Hello:
3840
const NEW_CURVES_SUPPORTED = areFFDHECurvesSupported(process.versions.openssl);
3941

42+
// Trust intermediate certificates from the trusted CA list too. Without this, trusted CAs
43+
// are only used when they are self-signed root certificates. Seems to cause issues in Node v20
44+
// in HTTP/2 tests, so disabled below the supported v22 version.
45+
const allowPartialTrustChain = semver.satisfies(process.version, '>=22.9.0');
46+
4047
const SSL_OP_LEGACY_SERVER_CONNECT = 1 << 2;
4148
const SSL_OP_TLSEXT_PADDING = 1 << 4;
4249
const SSL_OP_NO_ENCRYPT_THEN_MAC = 1 << 19;
@@ -49,7 +56,10 @@ export function getUpstreamTlsOptions({
4956

5057
ignoreHostHttpsErrors,
5158
clientCertificateHostMap,
52-
trustedCAs
59+
trustedCAs,
60+
61+
connection,
62+
tryHttp2Upstream
5363
}: {
5464
// The effective hostname & port we're connecting to - note that this isn't exactly
5565
// the same as the destination (e.g. if you tunnel to an IP but set a hostname via SNI
@@ -60,7 +70,13 @@ export function getUpstreamTlsOptions({
6070
// The general config that's relevant to this request:
6171
ignoreHostHttpsErrors: string[] | boolean,
6272
clientCertificateHostMap: { [host: string]: { pfx: Buffer, passphrase?: string } },
63-
trustedCAs: Array<string> | undefined
73+
trustedCAs: Array<string> | undefined,
74+
75+
// The downstream connection, used to mirror client hellos.
76+
connection?: Connection,
77+
78+
// Whether we're going to attempt HTTP/2 upstream, required to control our ALPN configuration.
79+
tryHttp2Upstream?: boolean
6480
}): tls.ConnectionOptions {
6581
const strictHttpsChecks = shouldUseStrictHttps(hostname, port, ignoreHostHttpsErrors);
6682

@@ -70,12 +86,53 @@ export function getUpstreamTlsOptions({
7086
clientCertificateHostMap['*'] ||
7187
{};
7288

73-
return {
89+
// Allow connecting to old servers that don't support secure renegotiation, iff not strict:
90+
const maybeLegacyConnect = strictHttpsChecks ? 0 : SSL_OP_LEGACY_SERVER_CONNECT;
91+
92+
const trustOptions: tls.SecureContextOptions = {
93+
allowPartialTrustChain,
94+
...(trustedCAs ? { ca: trustedCAs } : {}),
95+
...clientCert
96+
};
97+
98+
const connectionOptions: tls.ConnectionOptions = {
7499
servername: hostname && !isIP(hostname)
75100
? hostname
76101
: undefined, // Can't send IPs in SNI
102+
rejectUnauthorized: strictHttpsChecks
103+
};
104+
105+
// If the connection carries an inbound client hello to mirror, reproduce its TLS fingerprint
106+
// upstream instead of our default.
107+
const clientHello = connection?.[TlsClientHello];
108+
if (connection && clientHello) {
109+
const impersonationConfig = buildTlsImpersonationConfig(connection, clientHello, {
110+
...trustOptions,
111+
...(maybeLegacyConnect ? { secureOptions: maybeLegacyConnect } : {}),
112+
security: strictHttpsChecks ? 'secure' : 'insecure'
113+
});
114+
115+
// This should only be undefined if impersonation isn't supported:
116+
if (impersonationConfig) {
117+
// Mirror the client's ALPN, filtered to protocols we want upstream.
118+
// N.b. http2-wrapper currently overrides ALPN on the H2 path regardless (for now).
119+
const speakableUpstreamAlpn = tryHttp2Upstream ? ['h2', 'http/1.1'] : ['http/1.1'];
120+
const mirroredAlpn = impersonationConfig.tlsOptions.ALPNProtocols
121+
?.filter(p => speakableUpstreamAlpn.includes(p));
122+
123+
return {
124+
...connectionOptions,
125+
...impersonationConfig.tlsOptions,
126+
ALPNProtocols: mirroredAlpn?.length ? mirroredAlpn : undefined
127+
};
128+
}
129+
}
130+
131+
// Default fingerprint, emulating a Firefox v103 client hello to limit TLS fingerprinting issues:
132+
return {
133+
...connectionOptions,
134+
...trustOptions,
77135

78-
// We precisely control the various TLS parameters here to limit TLS fingerprinting issues:
79136
ecdhCurve: [
80137
'X25519',
81138
'prime256v1', // N.B. Equivalent to secp256r1
@@ -127,32 +184,11 @@ export function getUpstreamTlsOptions({
127184
: []
128185
)
129186
].join(':'),
130-
secureOptions: strictHttpsChecks
131-
? SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC
132-
: SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC | SSL_OP_LEGACY_SERVER_CONNECT,
133-
...({
134-
// Valid, but not included in Node.js TLS module types:
135-
requestOSCP: true
136-
} as any),
137-
138-
// Trust intermediate certificates from the trusted CA list too. Without this, trusted CAs
139-
// are only used when they are self-signed root certificates. Seems to cause issues in Node v20
140-
// in HTTP/2 tests, so disabled below the supported v22 version.
141-
allowPartialTrustChain: semver.satisfies(process.version, '>=22.9.0'),
187+
secureOptions: SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC | maybeLegacyConnect,
188+
requestOCSP: true,
142189

143190
// Allow TLSv1, if !strict:
144-
minVersion: strictHttpsChecks ? tls.DEFAULT_MIN_VERSION : 'TLSv1',
145-
146-
// Skip certificate validation entirely, if not strict:
147-
rejectUnauthorized: strictHttpsChecks,
148-
149-
// Override the set of trusted CAs, if configured to do so:
150-
...(trustedCAs ? {
151-
ca: trustedCAs
152-
} : {}),
153-
154-
// Use a client cert, if one matches for this hostname+port:
155-
...clientCert
191+
minVersion: strictHttpsChecks ? tls.DEFAULT_MIN_VERSION : 'TLSv1'
156192
}
157193
}
158194

src/rules/requests/request-step-definitions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,7 @@ export interface SerializedPassThroughData {
682682
clientCertificateHostMap?: { [host: string]: { pfx: string, passphrase?: string } };
683683
lookupOptions?: PassThroughLookupOptions;
684684
simulateConnectionErrors?: boolean;
685+
mirrorTlsFingerprint?: boolean;
685686

686687
transformRequest?: Replace<RequestTransform, {
687688
'replaceBody'?: string, // Serialized as base64 buffer
@@ -756,6 +757,8 @@ export class PassThroughStep extends Serializable implements RequestStepDefiniti
756757

757758
public readonly simulateConnectionErrors: boolean;
758759

760+
public readonly mirrorTlsFingerprint: boolean;
761+
759762
constructor(options: PassThroughStepOptions = {}) {
760763
super();
761764

@@ -767,6 +770,7 @@ export class PassThroughStep extends Serializable implements RequestStepDefiniti
767770
this.lookupOptions = options.lookupOptions;
768771
this.proxyConfig = options.proxyConfig;
769772
this.simulateConnectionErrors = !!options.simulateConnectionErrors;
773+
this.mirrorTlsFingerprint = !!options.mirrorTlsFingerprint;
770774

771775
this.extraCACertificates = options.additionalTrustedCAs || [];
772776

@@ -919,6 +923,7 @@ export class PassThroughStep extends Serializable implements RequestStepDefiniti
919923
proxyConfig: serializeProxyConfig(this.proxyConfig, channel),
920924
lookupOptions: this.lookupOptions,
921925
simulateConnectionErrors: this.simulateConnectionErrors,
926+
mirrorTlsFingerprint: this.mirrorTlsFingerprint,
922927
ignoreHostCertificateErrors: this.ignoreHostHttpsErrors,
923928
extraCACertificates: this.extraCACertificates.map((certObject) => {
924929
// We use toString to make sure that buffers always end up as

src/rules/requests/request-step-impls.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,13 @@ export class PassThroughStepImpl extends PassThroughStep {
799799
port: effectivePort,
800800
ignoreHostHttpsErrors: this.ignoreHostHttpsErrors,
801801
clientCertificateHostMap: this.clientCertificateHostMap,
802-
trustedCAs
802+
trustedCAs,
803+
804+
// Pass the connection through to mirror its inbound TLS fingerprint upstream,
805+
// if that's enabled (getUpstreamTlsOptions reads the hello from it):
806+
...(this.mirrorTlsFingerprint && protocol === 'https:'
807+
? { connection, tryHttp2Upstream: shouldTryH2Upstream }
808+
: {})
803809
})
804810
}, (serverRes) => (async () => {
805811
serverRes.on('error', (e: any) => {
@@ -1423,6 +1429,7 @@ export class PassThroughStepImpl extends PassThroughStep {
14231429
} as ResponseTransform : undefined,
14241430
lookupOptions: data.lookupOptions,
14251431
simulateConnectionErrors: !!data.simulateConnectionErrors,
1432+
mirrorTlsFingerprint: !!data.mirrorTlsFingerprint,
14261433
ignoreHostHttpsErrors: data.ignoreHostCertificateErrors,
14271434
additionalTrustedCAs: data.extraCACertificates,
14281435
clientCertificateHostMap: _.mapValues(data.clientCertificateHostMap,

src/server/http-combo-server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
LastTunnelAddress,
3939
LastHopEncrypted,
4040
TlsMetadata,
41+
TlsClientHello,
4142
TlsSetupCompleted,
4243
SocketMetadata,
4344
Expects100Continue,
@@ -366,6 +367,7 @@ export async function createComboServer(options: ComboServerOptions): Promise<De
366367
// With TLS metadata, we only propagate directly from parent sockets, not through
367368
// CONNECT etc - we only want it if the final hop is TLS, previous values don't matter.
368369
socket[TlsMetadata] ??= parentSocket[TlsMetadata];
370+
socket[TlsClientHello] ??= parentSocket[TlsClientHello];
369371
} else if (!socket[SocketTimingInfo]) {
370372
socket[SocketTimingInfo] = buildSocketTimingInfo();
371373
}
@@ -383,6 +385,7 @@ export async function createComboServer(options: ComboServerOptions): Promise<De
383385
server!.on('session', (session) => {
384386
session.once('remoteSettings', () => {
385387
(session.socket as tls.TLSSocket)[TlsSetupCompleted] = true;
388+
session[TlsClientHello] ??= session.initialSocket?.[TlsClientHello];
386389
});
387390
});
388391

@@ -542,6 +545,10 @@ function analyzeAndMaybePassThroughTls(
542545
ja4Fingerprint: calculateJa4(helloData)
543546
};
544547

548+
// Keep the raw parsed hello too, so it can be mirrored into the upstream TLS
549+
// connection later (fingerprint impersonation):
550+
socket[TlsClientHello] = helloData;
551+
545552
if (shouldPassThrough(upstreamDestination?.hostname, passThroughPatterns, interceptOnlyPatterns)) {
546553
passthroughListener(socket, upstreamDestination.hostname, upstreamDestination.port);
547554
return; // Do not continue with TLS

src/util/socket-extensions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type * as streams from 'stream';
22
import type * as net from 'net';
33
import type * as tls from 'tls';
4+
import type { TlsClientHelloMessage } from 'read-tls-client-hello';
45
import { TlsSocketMetadata } from '../types';
56

67
// We store a bunch of metadata that we directly attach to sockets, TLS
@@ -16,6 +17,7 @@ export const SocketTimingInfo = Symbol('socket-timing-info');
1617
export const SocketMetadata = Symbol('socket-metadata');
1718
export const Expects100Continue = Symbol('expects-100-continue');
1819
export const UpstreamConnectionAgents = Symbol('upstream-connection-agents');
20+
export const TlsClientHello = Symbol('tls-client-hello');
1921

2022
/**
2123
* The set of upstream agents dedicated to a single downstream connection (or
@@ -73,6 +75,7 @@ declare module 'net' {
7375
[TlsMetadata]?: TlsSocketMetadata;
7476
[InitialRemoteAddress]?: string;
7577
[InitialRemotePort]?: number;
78+
[TlsClientHello]?: TlsClientHelloMessage;
7679

7780
/**
7881
* Arbitrary custom metadata that may be added during socket processing,
@@ -112,6 +115,8 @@ declare module 'tls' {
112115
*/
113116
[InitialRemoteAddress]?: string;
114117
[InitialRemotePort]?: number;
118+
119+
[TlsClientHello]?: TlsClientHelloMessage;
115120
}
116121
}
117122

@@ -138,6 +143,8 @@ declare module 'http2' {
138143

139144
// Upstream agents for this session (the connection for all its H2 requests).
140145
[UpstreamConnectionAgents]?: UpstreamConnectionAgentMap;
146+
147+
[TlsClientHello]?: TlsClientHelloMessage;
141148
}
142149

143150
interface ServerHttp2Stream {
@@ -149,6 +156,8 @@ declare module 'http2' {
149156

150157
// Upstream agents owned by this H2 CONNECT tunnel stream (plaintext requests within it).
151158
[UpstreamConnectionAgents]?: UpstreamConnectionAgentMap;
159+
160+
[TlsClientHello]?: TlsClientHelloMessage;
152161
}
153162
}
154163

src/util/tls-impersonation.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type * as tls from 'tls';
2+
import type { TlsClientHelloMessage } from 'read-tls-client-hello';
3+
4+
import type { Connection } from '../rules/http-agents';
5+
6+
// We declare the bits of tls-impersonate we use locally, rather than importing its types,
7+
// because it's an optional dep so might not be present (old Node CI).
8+
export interface ImpersonateResult {
9+
tlsOptions: {
10+
secureContext: tls.SecureContext;
11+
ALPNProtocols?: string[];
12+
requestOCSP?: boolean;
13+
};
14+
unsupported: Array<{ kind: string, id: number, reason: string }>;
15+
}
16+
17+
export interface ImpersonateOptions extends tls.SecureContextOptions {
18+
security?: 'secure' | 'insecure';
19+
}
20+
21+
interface TlsImpersonate {
22+
isSupported(): boolean;
23+
impersonateFromClientHello(
24+
hello: TlsClientHelloMessage,
25+
options?: ImpersonateOptions
26+
): ImpersonateResult;
27+
}
28+
29+
let impersonateModule: TlsImpersonate | null | undefined;
30+
31+
/**
32+
* Load tls-impersonate, if it's usable on this runtime. It's an optional, native, Node-only module
33+
* (fully effective on Node 26.4+, usable with reduced fidelity from 24.15) and may be entirely
34+
* absent (unsupported platform/Node, or no native build). We tolerate all of that by disabling
35+
* mirroring, so callers fall back to Mockttp's default upstream fingerprint.
36+
*
37+
* This is only ever reached when mirroring is explicitly enabled, so if the module isn't usable we
38+
* warn once (including on older Node): the caller asked to mirror fingerprints and it won't happen.
39+
*/
40+
function loadImpersonate(): TlsImpersonate | null {
41+
if (impersonateModule !== undefined) return impersonateModule;
42+
43+
try {
44+
const loaded = require('tls-impersonate') as TlsImpersonate;
45+
impersonateModule = loaded?.isSupported?.() ? loaded : null;
46+
} catch {
47+
impersonateModule = null;
48+
}
49+
50+
if (!impersonateModule) {
51+
console.warn('TLS fingerprint mirroring is enabled but unavailable - ' +
52+
'upstream requests will use the default TLS fingerprint instead.');
53+
}
54+
55+
return impersonateModule;
56+
}
57+
58+
const impersonationCache = new WeakMap<Connection, ImpersonateResult | undefined>();
59+
60+
/**
61+
* Get (building & caching once per connection) a TLS SecureContext + connect options that
62+
* reproduce the inbound ClientHello, folding in the given context options (trusted CAs, client
63+
* cert). Returns undefined if impersonation is unavailable or fails, so callers fall back to
64+
* Mockttp's default upstream fingerprint.
65+
*/
66+
export function buildTlsImpersonationConfig(
67+
connection: Connection,
68+
hello: TlsClientHelloMessage,
69+
options: ImpersonateOptions
70+
): ImpersonateResult | undefined {
71+
if (impersonationCache.has(connection)) return impersonationCache.get(connection);
72+
73+
const impersonate = loadImpersonate();
74+
let result: ImpersonateResult | undefined;
75+
76+
if (impersonate) {
77+
try {
78+
result = impersonate.impersonateFromClientHello(hello, options);
79+
} catch (e) {
80+
console.warn(`Failed to impersonate inbound TLS fingerprint: ${(e as Error).message}`);
81+
}
82+
}
83+
84+
impersonationCache.set(connection, result);
85+
return result;
86+
}

0 commit comments

Comments
 (0)