Skip to content

Commit 8433cfd

Browse files
committed
Commonize passthrough logic for client certs, strict HTTPS & trusted CAs
Previously websockets & HTTP handled this slightly separately - it's helpful to move as much as possible of this into a single place. More to do around that generally, but step by step.
1 parent 0e8be83 commit 8433cfd

File tree

3 files changed

+129
-122
lines changed

3 files changed

+129
-122
lines changed

src/rules/passthrough-handling.ts

Lines changed: 112 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -43,83 +43,117 @@ const SSL_OP_NO_ENCRYPT_THEN_MAC = 1 << 19;
4343

4444
// All settings are designed to exactly match Firefox v103, since that's a good baseline
4545
// that seems to be widely accepted and is easy to emulate from Node.js.
46-
export const getUpstreamTlsOptions = ({ strictHttpsChecks, serverName }: {
47-
strictHttpsChecks: boolean,
48-
serverName?: string
49-
}): tls.ConnectionOptions => ({
50-
servername: serverName && !isIP(serverName)
51-
? serverName
52-
: undefined, // Can't send IPs in SNI
53-
ecdhCurve: [
54-
'X25519',
55-
'prime256v1', // N.B. Equivalent to secp256r1
56-
'secp384r1',
57-
'secp521r1',
58-
...(NEW_CURVES_SUPPORTED
59-
? [ // Only available with OpenSSL v3+:
60-
'ffdhe2048',
61-
'ffdhe3072'
62-
] : []
63-
)
64-
].join(':'),
65-
sigalgs: [
66-
'ecdsa_secp256r1_sha256',
67-
'ecdsa_secp384r1_sha384',
68-
'ecdsa_secp521r1_sha512',
69-
'rsa_pss_rsae_sha256',
70-
'rsa_pss_rsae_sha384',
71-
'rsa_pss_rsae_sha512',
72-
'rsa_pkcs1_sha256',
73-
'rsa_pkcs1_sha384',
74-
'rsa_pkcs1_sha512',
75-
'ECDSA+SHA1',
76-
'rsa_pkcs1_sha1'
77-
].join(':'),
78-
ciphers: [
79-
'TLS_AES_128_GCM_SHA256',
80-
'TLS_CHACHA20_POLY1305_SHA256',
81-
'TLS_AES_256_GCM_SHA384',
82-
'ECDHE-ECDSA-AES128-GCM-SHA256',
83-
'ECDHE-RSA-AES128-GCM-SHA256',
84-
'ECDHE-ECDSA-CHACHA20-POLY1305',
85-
'ECDHE-RSA-CHACHA20-POLY1305',
86-
'ECDHE-ECDSA-AES256-GCM-SHA384',
87-
'ECDHE-RSA-AES256-GCM-SHA384',
88-
'ECDHE-ECDSA-AES256-SHA',
89-
'ECDHE-ECDSA-AES128-SHA',
90-
'ECDHE-RSA-AES128-SHA',
91-
'ECDHE-RSA-AES256-SHA',
92-
'AES128-GCM-SHA256',
93-
'AES256-GCM-SHA384',
94-
'AES128-SHA',
95-
'AES256-SHA',
96-
97-
// This magic cipher is the very obtuse way that OpenSSL downgrades the overall
98-
// security level to allow various legacy settings, protocols & ciphers:
99-
...(!strictHttpsChecks
100-
? ['@SECLEVEL=0']
101-
: []
102-
)
103-
].join(':'),
104-
secureOptions: strictHttpsChecks
105-
? SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC
106-
: SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC | SSL_OP_LEGACY_SERVER_CONNECT,
107-
...({
108-
// Valid, but not included in Node.js TLS module types:
109-
requestOSCP: true
110-
} as any),
111-
112-
// Trust intermediate certificates from the trusted CA list too. Without this, trusted CAs
113-
// are only used when they are self-signed root certificates. Seems to cause issues in Node v20
114-
// in HTTP/2 tests, so disabled below the supported v22 version.
115-
allowPartialTrustChain: semver.satisfies(process.version, '>=22.9.0'),
116-
117-
// Allow TLSv1, if !strict:
118-
minVersion: strictHttpsChecks ? tls.DEFAULT_MIN_VERSION : 'TLSv1',
119-
120-
// Skip certificate validation entirely, if not strict:
121-
rejectUnauthorized: strictHttpsChecks,
122-
});
46+
export function getUpstreamTlsOptions({
47+
hostname,
48+
port,
49+
50+
ignoreHostHttpsErrors,
51+
clientCertificateHostMap,
52+
trustedCAs
53+
}: {
54+
// The effective hostname & port we're connecting to - note that this isn't exactly
55+
// the same as the destination (e.g. if you tunnel to an IP but set a hostname via SNI
56+
// then this is the hostname, not the IP).
57+
hostname: string,
58+
port: number,
59+
60+
// The general config that's relevant to this request:
61+
ignoreHostHttpsErrors: string[] | boolean,
62+
clientCertificateHostMap: { [host: string]: { pfx: Buffer, passphrase?: string } },
63+
trustedCAs: Array<string> | undefined
64+
}): tls.ConnectionOptions {
65+
const strictHttpsChecks = shouldUseStrictHttps(hostname, port, ignoreHostHttpsErrors);
66+
67+
const hostWithPort = `${hostname}:${port}`;
68+
const clientCert = clientCertificateHostMap[hostWithPort] ||
69+
clientCertificateHostMap[hostname] ||
70+
{};
71+
72+
return {
73+
servername: hostname && !isIP(hostname)
74+
? hostname
75+
: undefined, // Can't send IPs in SNI
76+
77+
// We precisely control the various TLS parameters here to limit TLS fingerprinting issues:
78+
ecdhCurve: [
79+
'X25519',
80+
'prime256v1', // N.B. Equivalent to secp256r1
81+
'secp384r1',
82+
'secp521r1',
83+
...(NEW_CURVES_SUPPORTED
84+
? [ // Only available with OpenSSL v3+:
85+
'ffdhe2048',
86+
'ffdhe3072'
87+
] : []
88+
)
89+
].join(':'),
90+
sigalgs: [
91+
'ecdsa_secp256r1_sha256',
92+
'ecdsa_secp384r1_sha384',
93+
'ecdsa_secp521r1_sha512',
94+
'rsa_pss_rsae_sha256',
95+
'rsa_pss_rsae_sha384',
96+
'rsa_pss_rsae_sha512',
97+
'rsa_pkcs1_sha256',
98+
'rsa_pkcs1_sha384',
99+
'rsa_pkcs1_sha512',
100+
'ECDSA+SHA1',
101+
'rsa_pkcs1_sha1'
102+
].join(':'),
103+
ciphers: [
104+
'TLS_AES_128_GCM_SHA256',
105+
'TLS_CHACHA20_POLY1305_SHA256',
106+
'TLS_AES_256_GCM_SHA384',
107+
'ECDHE-ECDSA-AES128-GCM-SHA256',
108+
'ECDHE-RSA-AES128-GCM-SHA256',
109+
'ECDHE-ECDSA-CHACHA20-POLY1305',
110+
'ECDHE-RSA-CHACHA20-POLY1305',
111+
'ECDHE-ECDSA-AES256-GCM-SHA384',
112+
'ECDHE-RSA-AES256-GCM-SHA384',
113+
'ECDHE-ECDSA-AES256-SHA',
114+
'ECDHE-ECDSA-AES128-SHA',
115+
'ECDHE-RSA-AES128-SHA',
116+
'ECDHE-RSA-AES256-SHA',
117+
'AES128-GCM-SHA256',
118+
'AES256-GCM-SHA384',
119+
'AES128-SHA',
120+
'AES256-SHA',
121+
122+
// This magic cipher is the very obtuse way that OpenSSL downgrades the overall
123+
// security level to allow various legacy settings, protocols & ciphers:
124+
...(!strictHttpsChecks
125+
? ['@SECLEVEL=0']
126+
: []
127+
)
128+
].join(':'),
129+
secureOptions: strictHttpsChecks
130+
? SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC
131+
: SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC | SSL_OP_LEGACY_SERVER_CONNECT,
132+
...({
133+
// Valid, but not included in Node.js TLS module types:
134+
requestOSCP: true
135+
} as any),
136+
137+
// Trust intermediate certificates from the trusted CA list too. Without this, trusted CAs
138+
// are only used when they are self-signed root certificates. Seems to cause issues in Node v20
139+
// in HTTP/2 tests, so disabled below the supported v22 version.
140+
allowPartialTrustChain: semver.satisfies(process.version, '>=22.9.0'),
141+
142+
// Allow TLSv1, if !strict:
143+
minVersion: strictHttpsChecks ? tls.DEFAULT_MIN_VERSION : 'TLSv1',
144+
145+
// Skip certificate validation entirely, if not strict:
146+
rejectUnauthorized: strictHttpsChecks,
147+
148+
// Override the set of trusted CAs, if configured to do so:
149+
...(trustedCAs ? {
150+
ca: trustedCAs
151+
} : {}),
152+
153+
// Use a client cert, if one matches for this hostname+port:
154+
...clientCert
155+
}
156+
}
123157

124158
export async function getTrustedCAs(
125159
trustedCAs: Array<CADefinition> | undefined,
@@ -478,7 +512,7 @@ export function getResponseContentLengthAfterModification(
478512

479513
// Function to check if we should skip https errors for the current hostname and port,
480514
// based on the given config
481-
export function shouldUseStrictHttps(
515+
function shouldUseStrictHttps(
482516
hostname: string,
483517
port: number,
484518
ignoreHostHttpsErrors: string[] | boolean

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

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
} from "../../types";
2323

2424
import { MaybePromise, ErrorLike, isErrorLike, delay } from '@httptoolkit/util';
25-
import { isAbsoluteUrl, getEffectivePort, getDefaultPort } from '../../util/url';
25+
import { isAbsoluteUrl, getEffectivePort } from '../../util/url';
2626
import {
2727
waitForCompletedRequest,
2828
buildBodyReader,
@@ -39,7 +39,6 @@ import {
3939
rawHeadersToObjectPreservingCase,
4040
flattenPairedRawHeaders,
4141
pairFlatRawHeaders,
42-
findRawHeaderIndex,
4342
dropDefaultHeaders,
4443
validateHeader,
4544
updateRawHeaders,
@@ -81,7 +80,6 @@ import {
8180
MODIFIABLE_PSEUDOHEADERS,
8281
buildOverriddenBody,
8382
getUpstreamTlsOptions,
84-
shouldUseStrictHttps,
8583
getClientRelativeHostname,
8684
getDnsLookupFunction,
8785
getTrustedCAs,
@@ -656,23 +654,7 @@ export class PassThroughStepImpl extends PassThroughStep {
656654
}
657655

658656
const effectivePort = getEffectivePort({ protocol, port });
659-
660-
const strictHttpsChecks = shouldUseStrictHttps(
661-
hostname!,
662-
effectivePort,
663-
this.ignoreHostHttpsErrors
664-
);
665-
666-
// Use a client cert if it's listed for the host+port or whole hostname
667-
const hostWithPort = `${hostname}:${effectivePort}`;
668-
const clientCert = this.clientCertificateHostMap[hostWithPort] ||
669-
this.clientCertificateHostMap[hostname!] ||
670-
{};
671-
672-
const trustedCerts = await this.trustedCACertificates();
673-
const caConfig = trustedCerts
674-
? { ca: trustedCerts }
675-
: {};
657+
const trustedCAs = await this.trustedCACertificates();
676658

677659
// We only do H2 upstream for HTTPS. Http2-wrapper doesn't support H2C, it's rarely used
678660
// and we can't use ALPN to detect HTTP/2 support cleanly.
@@ -753,9 +735,13 @@ export class PassThroughStepImpl extends PassThroughStep {
753735
agent,
754736

755737
// TLS options:
756-
...getUpstreamTlsOptions({ strictHttpsChecks, serverName: hostname }),
757-
...clientCert,
758-
...caConfig
738+
...getUpstreamTlsOptions({
739+
hostname,
740+
port: effectivePort,
741+
ignoreHostHttpsErrors: this.ignoreHostHttpsErrors,
742+
clientCertificateHostMap: this.clientCertificateHostMap,
743+
trustedCAs
744+
})
759745
}, (serverRes) => (async () => {
760746
serverRes.on('error', (e: any) => {
761747
reportUpstreamAbort(e)

src/rules/websockets/websocket-step-impls.ts

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { getDefaultPort, getEffectivePort } from '../../util/url';
2828
import { resetOrDestroy } from '../../util/socket-util';
2929
import { isHttp2 } from '../../util/request-utils';
3030
import {
31-
findRawHeader,
3231
findRawHeaders,
3332
objectHeadersToRaw,
3433
pairFlatRawHeaders,
@@ -43,7 +42,6 @@ import {
4342
getUpstreamTlsOptions,
4443
getClientRelativeHostname,
4544
getDnsLookupFunction,
46-
shouldUseStrictHttps,
4745
getTrustedCAs,
4846
getEffectiveHostname,
4947
applyDestinationTransforms
@@ -319,22 +317,7 @@ export class PassThroughWebSocketStepImpl extends PassThroughWebSocketStep {
319317
const effectiveHostname = parsedUrl.hostname!; // N.b. not necessarily the same as destination
320318
const effectivePort = getEffectivePort(parsedUrl);
321319

322-
const strictHttpsChecks = shouldUseStrictHttps(
323-
effectiveHostname,
324-
effectivePort,
325-
this.ignoreHostHttpsErrors
326-
);
327-
328-
// Use a client cert if it's listed for the host+port or whole hostname
329-
const hostWithPort = `${parsedUrl.hostname}:${effectivePort}`;
330-
const clientCert = this.clientCertificateHostMap[hostWithPort] ||
331-
this.clientCertificateHostMap[effectiveHostname] ||
332-
{};
333-
334-
const trustedCerts = await this.trustedCACertificates();
335-
const caConfig = trustedCerts
336-
? { ca: trustedCerts }
337-
: {};
320+
const trustedCAs = await this.trustedCACertificates();
338321

339322
const proxySettingSource = assertParamDereferenced(this.proxyConfig) as ProxySettingSource;
340323

@@ -385,9 +368,13 @@ export class PassThroughWebSocketStepImpl extends PassThroughWebSocketStep {
385368
) as { [key: string]: string }, // Simplify to string - doesn't matter though, only used by http module anyway
386369

387370
// TLS options:
388-
...getUpstreamTlsOptions({ strictHttpsChecks, serverName: effectiveHostname }),
389-
...clientCert,
390-
...caConfig
371+
...getUpstreamTlsOptions({
372+
hostname: effectiveHostname,
373+
port: effectivePort,
374+
ignoreHostHttpsErrors: this.ignoreHostHttpsErrors,
375+
clientCertificateHostMap: this.clientCertificateHostMap,
376+
trustedCAs,
377+
})
391378
} as WebSocket.ClientOptions & { lookup: any, maxPayload: number });
392379

393380
if (options.emitEventCallback) {

0 commit comments

Comments
 (0)