Skip to content

Commit 75ddb7b

Browse files
committed
Fix SOCKS handling of IPs with SNI passthrough & transforms
Previously, a SOCKS request to an IP (not DNS) resulted in that IP still appearing in a few places, notably including transforms/callbacks & upstream SNI when doing passthrough. This was a bug, as the effective hostname (from Host header or inbound SNI) should be used instead, with the IP only used for the actual TCP connection destination.
1 parent fe42cb5 commit 75ddb7b

File tree

5 files changed

+350
-69
lines changed

5 files changed

+350
-69
lines changed

src/rules/passthrough-handling.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { Buffer } from 'buffer';
22
import * as fs from 'fs/promises';
33
import * as tls from 'tls';
44
import * as url from 'url';
5+
import type * as net from 'net';
56

67
import * as _ from 'lodash';
78
import { oneLine } from 'common-tags';
89
import CacheableLookup from 'cacheable-lookup';
910
import * as semver from 'semver';
11+
import { ErrorLike, unreachableCheck } from '@httptoolkit/util';
1012

1113
import { CompletedBody, Headers, RawHeaders } from '../types';
1214
import { byteLength } from '../util/util';
@@ -15,8 +17,9 @@ import { isIP, isLocalhostAddress, normalizeIP } from '../util/ip-utils';
1517
import { CachedDns, dnsLookup, DnsLookupFunction } from '../util/dns';
1618
import { isMockttpBody, encodeBodyBuffer } from '../util/request-utils';
1719
import { areFFDHECurvesSupported } from '../util/openssl-compat';
18-
import { ErrorLike, unreachableCheck } from '@httptoolkit/util';
1920
import { findRawHeaderIndex, getHeaderValue } from '../util/header-utils';
21+
import { getDefaultPort } from '../util/url';
22+
import { TlsMetadata } from '../util/socket-extensions';
2023

2124
import {
2225
CallbackRequestResult,
@@ -28,7 +31,6 @@ import {
2831
PassThroughInitialTransforms,
2932
PassThroughLookupOptions
3033
} from './passthrough-handling-definitions';
31-
import { getDefaultPort } from '../util/url';
3234
import { applyMatchReplace } from './match-replace';
3335

3436
// TLS settings for proxied connections, intended to avoid TLS fingerprint blocking
@@ -41,7 +43,13 @@ const SSL_OP_NO_ENCRYPT_THEN_MAC = 1 << 19;
4143

4244
// All settings are designed to exactly match Firefox v103, since that's a good baseline
4345
// that seems to be widely accepted and is easy to emulate from Node.js.
44-
export const getUpstreamTlsOptions = (strictChecks: boolean): tls.SecureContextOptions => ({
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
4553
ecdhCurve: [
4654
'X25519',
4755
'prime256v1', // N.B. Equivalent to secp256r1
@@ -88,12 +96,12 @@ export const getUpstreamTlsOptions = (strictChecks: boolean): tls.SecureContextO
8896

8997
// This magic cipher is the very obtuse way that OpenSSL downgrades the overall
9098
// security level to allow various legacy settings, protocols & ciphers:
91-
...(!strictChecks
99+
...(!strictHttpsChecks
92100
? ['@SECLEVEL=0']
93101
: []
94102
)
95103
].join(':'),
96-
secureOptions: strictChecks
104+
secureOptions: strictHttpsChecks
97105
? SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC
98106
: SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC | SSL_OP_LEGACY_SERVER_CONNECT,
99107
...({
@@ -107,10 +115,10 @@ export const getUpstreamTlsOptions = (strictChecks: boolean): tls.SecureContextO
107115
allowPartialTrustChain: semver.satisfies(process.version, '>=22.9.0'),
108116

109117
// Allow TLSv1, if !strict:
110-
minVersion: strictChecks ? tls.DEFAULT_MIN_VERSION : 'TLSv1',
118+
minVersion: strictHttpsChecks ? tls.DEFAULT_MIN_VERSION : 'TLSv1',
111119

112120
// Skip certificate validation entirely, if not strict:
113-
rejectUnauthorized: strictChecks,
121+
rejectUnauthorized: strictHttpsChecks,
114122
});
115123

116124
export async function getTrustedCAs(
@@ -182,21 +190,23 @@ export async function buildOverriddenBody(
182190
}
183191

184192
/**
185-
* Effectively match the slightly-different-context logic in MockttpServer for showing a
186-
* request's destination within the URL. We prioritise domain names over IPs, and
187-
* derive the most appropriate name available. In this case, we drop the port, since that's
188-
* always specified elsewhere.
193+
* Effectively match the slightly-different-context logic in MockttpServer for generating a 'name'
194+
* for a request's destination (e.g. in the URL). We prioritise domain names over IPs, and
195+
* derive the most appropriate name available. In this method we consider only hostnames, so we
196+
* drop the port, as that's always specified elsewhere.
189197
*/
190-
export function getUrlHostname(
198+
export function getEffectiveHostname(
191199
destinationHostname: string | null,
200+
socket: net.Socket,
192201
rawHeaders: RawHeaders
193202
) {
194203
return destinationHostname && !isIP(destinationHostname)
195204
? destinationHostname
196205
: ( // Use header info rather than raw IPs, if we can:
197206
getHeaderValue(rawHeaders, ':authority') ??
198207
getHeaderValue(rawHeaders, 'host') ??
199-
destinationHostname ?? // Use destination if it's a bare IP, if we have nothing else
208+
socket[TlsMetadata]?.sniHostname ??
209+
destinationHostname ?? // Use bare IP destination if we have nothing else
200210
'localhost'
201211
).replace(/:\d+$/, '');
202212
}
@@ -223,8 +233,8 @@ function deriveUrlLinkedHeader(
223233
// existing value, we accept it but print a warning. This would be easy to
224234
// do if you mutate the existing headers, for example, and ignore the host.
225235
console.warn(oneLine`
226-
Passthrough callback overrode the URL and the ${headerName} header
227-
with mismatched values, which may be a mistake. The URL implies
236+
Passthrough callback set the URL and the ${headerName} header
237+
to mismatched values, which may be a mistake. The URL implies
228238
${expectedValue}, whilst the header was set to ${replacementValue}.
229239
`);
230240
}

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

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ import {
8686
getDnsLookupFunction,
8787
getTrustedCAs,
8888
buildUpstreamErrorTags,
89-
getUrlHostname,
89+
getEffectiveHostname,
9090
applyDestinationTransforms
9191
} from '../passthrough-handling';
9292

@@ -422,11 +422,17 @@ export class PassThroughStepImpl extends PassThroughStep {
422422
// Capture raw request data:
423423
let { method, url: reqUrl, rawHeaders, destination } = clientReq as OngoingRequest;
424424
let { protocol, pathname, search: query } = url.parse(reqUrl);
425-
let hostname: string = destination.hostname;
425+
const clientSocket = (clientReq as any).socket as net.Socket;
426+
427+
// Actual IP address or hostname
428+
let hostAddress = destination.hostname;
429+
// Same as hostAddress, unless it's an IP, in which case it's our best guess of the
430+
// functional 'name' for the host (from Host header or SNI).
431+
let hostname: string = getEffectiveHostname(hostAddress, clientSocket, rawHeaders);
426432
let port: string | null | undefined = destination.port.toString();
427433

428434
// Check if this request is a request loop:
429-
if (isSocketLoop(this.outgoingSockets, (clientReq as any).socket)) {
435+
if (isSocketLoop(this.outgoingSockets, clientSocket)) {
430436
throw new Error(oneLine`
431437
Passthrough loop detected. This probably means you're sending a request directly
432438
to a passthrough endpoint, which is forwarding it to the target URL, which is a
@@ -444,8 +450,8 @@ export class PassThroughStepImpl extends PassThroughStep {
444450

445451
const isH2Downstream = isHttp2(clientReq);
446452

447-
hostname = await getClientRelativeHostname(
448-
hostname,
453+
hostAddress = await getClientRelativeHostname(
454+
hostAddress,
449455
clientReq.remoteIpAddress,
450456
getDnsLookupFunction(this.lookupOptions)
451457
);
@@ -466,6 +472,8 @@ export class PassThroughStepImpl extends PassThroughStep {
466472
matchReplaceBody
467473
} = this.transformRequest;
468474

475+
const originalHostname = hostname;
476+
469477
({
470478
reqUrl,
471479
protocol,
@@ -484,6 +492,12 @@ export class PassThroughStepImpl extends PassThroughStep {
484492
query
485493
}));
486494

495+
// If you modify the hostname, we also treat that as modifying the
496+
// resulting destination in turn:
497+
if (hostname !== originalHostname) {
498+
hostAddress = hostname;
499+
}
500+
487501
if (replaceMethod) {
488502
method = replaceMethod;
489503
}
@@ -579,8 +593,7 @@ export class PassThroughStepImpl extends PassThroughStep {
579593

580594
if (modifiedReq?.response) {
581595
if (modifiedReq.response === 'close') {
582-
const socket: net.Socket = (clientReq as any).socket;
583-
socket.end();
596+
clientSocket.end();
584597
throw new AbortError('Connection closed intentionally by rule', 'E_RULE_BREQ_CLOSE');
585598
} else if (modifiedReq.response === 'reset') {
586599
requireSocketResetSupport();
@@ -594,18 +607,27 @@ export class PassThroughStepImpl extends PassThroughStep {
594607
}
595608

596609
method = modifiedReq?.method || method;
597-
reqUrl = modifiedReq?.url || reqUrl;
610+
611+
// Reparse the new URL, if necessary
612+
if (modifiedReq?.url) {
613+
if (!isAbsoluteUrl(modifiedReq?.url)) throw new Error("Overridden request URLs must be absolute");
614+
615+
reqUrl = modifiedReq.url;
616+
617+
const parsedUrl = url.parse(reqUrl);
618+
({ protocol, port, pathname, search: query } = parsedUrl);
619+
hostname = parsedUrl.hostname!;
620+
hostAddress = hostname;
621+
}
598622

599623
let headers = modifiedReq?.headers || clientHeaders;
600624

601625
// We need to make sure the Host/:authority header is updated correctly - following the user's returned value if
602626
// they provided one, but updating it if not to match the effective target URL of the request:
603-
const expectedTargetUrl = modifiedReq?.url ?? reqUrl;
604-
605627
Object.assign(headers,
606628
isH2Downstream
607-
? getH2HeadersAfterModification(expectedTargetUrl, clientHeaders, modifiedReq?.headers)
608-
: { 'host': getHostAfterModification(expectedTargetUrl, clientHeaders, modifiedReq?.headers) }
629+
? getH2HeadersAfterModification(reqUrl, clientHeaders, modifiedReq?.headers)
630+
: { 'host': getHostAfterModification(reqUrl, clientHeaders, modifiedReq?.headers) }
609631
);
610632

611633
validateCustomHeaders(
@@ -630,14 +652,6 @@ export class PassThroughStepImpl extends PassThroughStep {
630652
}
631653
}
632654

633-
// Reparse the new URL, if necessary
634-
if (modifiedReq?.url) {
635-
if (!isAbsoluteUrl(modifiedReq?.url)) throw new Error("Overridden request URLs must be absolute");
636-
const parsedUrl = url.parse(reqUrl);
637-
({ protocol, port, pathname, search: query } = parsedUrl);
638-
hostname = parsedUrl.hostname!;
639-
}
640-
641655
rawHeaders = objectHeadersToRaw(headers);
642656
}
643657

@@ -726,7 +740,7 @@ export class PassThroughStepImpl extends PassThroughStep {
726740
serverReq = await makeRequest({
727741
protocol,
728742
method,
729-
hostname,
743+
hostname: hostAddress,
730744
port,
731745
family,
732746
path: `${pathname || '/'}${query || ''}`,
@@ -739,7 +753,7 @@ export class PassThroughStepImpl extends PassThroughStep {
739753
agent,
740754

741755
// TLS options:
742-
...getUpstreamTlsOptions(strictHttpsChecks),
756+
...getUpstreamTlsOptions({ strictHttpsChecks, serverName: hostname }),
743757
...clientCert,
744758
...caConfig
745759
}, (serverRes) => (async () => {
@@ -944,7 +958,7 @@ export class PassThroughStepImpl extends PassThroughStep {
944958
}
945959

946960
if (modifiedRes === 'close') {
947-
(clientReq as any).socket.end();
961+
clientSocket.end();
948962
} else if (modifiedRes === 'reset') {
949963
requireSocketResetSupport();
950964
resetOrDestroy(clientReq);
@@ -1151,12 +1165,10 @@ export class PassThroughStepImpl extends PassThroughStep {
11511165
// Fire rule events, to allow in-depth debugging of upstream traffic & modifications,
11521166
// so anybody interested can see _exactly_ what we're sending upstream here:
11531167
if (options.emitEventCallback) {
1154-
const urlHost = getUrlHostname(hostname, rawHeaders);
1155-
11561168
options.emitEventCallback('passthrough-request-head', {
11571169
method,
11581170
protocol: protocol!.replace(/:$/, ''),
1159-
hostname: urlHost,
1171+
hostname,
11601172
port,
11611173
path: `${pathname || '/'}${query || ''}`,
11621174
rawHeaders

0 commit comments

Comments
 (0)