Skip to content

Commit f3a63f6

Browse files
committed
Expose URL-equivalent hostnames from passthrough-request-head events
With the new destination logic, we now have multiple ways to think about hostnames. This bring this event data in line with normal 'request' event URL hostnames, for easy comparison and a generally nicer experience.
1 parent e7afe49 commit f3a63f6

File tree

6 files changed

+95
-44
lines changed

6 files changed

+95
-44
lines changed

src/rules/passthrough-handling.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as semver from 'semver';
99
import { CompletedBody, Headers, RawHeaders } from '../types';
1010
import { byteLength } from '../util/util';
1111
import { asBuffer } from '../util/buffer-utils';
12-
import { isLocalhostAddress, normalizeIP } from '../util/socket-util';
12+
import { isIP, isLocalhostAddress, normalizeIP } from '../util/ip-utils';
1313
import { CachedDns, dnsLookup, DnsLookupFunction } from '../util/dns';
1414
import { isMockttpBody, encodeBodyBuffer } from '../util/request-utils';
1515
import { areFFDHECurvesSupported } from '../util/openssl-compat';
@@ -177,6 +177,26 @@ export async function buildOverriddenBody(
177177
return await encodeBodyBuffer(rawBuffer, headers);
178178
}
179179

180+
/**
181+
* Effectively match the slightly-different-context logic in MockttpServer for showing a
182+
* request's destination within the URL. We prioritise domain names over IPs, and
183+
* derive the most appropriate name available. In this case, we drop the port, since that's
184+
* always specified elsewhere.
185+
*/
186+
export function getUrlHostname(
187+
destinationHostname: string | null,
188+
rawHeaders: RawHeaders
189+
) {
190+
return destinationHostname && !isIP(destinationHostname)
191+
? destinationHostname
192+
: ( // Use header info rather than raw IPs, if we can:
193+
getHeaderValue(rawHeaders, ':authority') ??
194+
getHeaderValue(rawHeaders, 'host') ??
195+
destinationHostname ?? // Use destination if it's a bare IP, if we have nothing else
196+
'localhost'
197+
).replace(/:\d+$/, '');
198+
}
199+
180200
/**
181201
* If you override some headers, they have implications for the effective URL we send the
182202
* request to. If you override that and the URL at the same time, it gets complicated.

src/rules/requests/request-handlers.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import {
2121
} from "../../types";
2222

2323
import { MaybePromise, ErrorLike, isErrorLike } from '@httptoolkit/util';
24-
import { isAbsoluteUrl, getEffectivePort, getDestination } from '../../util/url';
24+
import { isAbsoluteUrl, getEffectivePort } from '../../util/url';
25+
import { isIP } from '../../util/ip-utils';
2526
import {
2627
waitForCompletedRequest,
2728
buildBodyReader,
@@ -81,7 +82,8 @@ import {
8182
getClientRelativeHostname,
8283
getDnsLookupFunction,
8384
getTrustedCAs,
84-
buildUpstreamErrorTags
85+
buildUpstreamErrorTags,
86+
getUrlHostname
8587
} from '../passthrough-handling';
8688

8789
import {
@@ -1157,10 +1159,12 @@ export class PassThroughHandler extends PassThroughHandlerDefinition {
11571159
// Fire rule events, to allow in-depth debugging of upstream traffic & modifications,
11581160
// so anybody interested can see _exactly_ what we're sending upstream here:
11591161
if (options.emitEventCallback) {
1162+
const urlHost = getUrlHostname(hostname, rawHeaders);
1163+
11601164
options.emitEventCallback('passthrough-request-head', {
11611165
method,
11621166
protocol: protocol!.replace(/:$/, ''),
1163-
hostname,
1167+
hostname: urlHost,
11641168
port,
11651169
path,
11661170
rawHeaders

src/server/mockttp-server.ts

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ import {
5454
getHostFromAbsoluteUrl,
5555
getDestination,
5656
normalizeHost,
57-
getDefaultPort
5857
} from "../util/url";
58+
import { isIP } from "../util/ip-utils";
5959
import {
6060
buildRawSocketEventData,
6161
buildTlsSocketEventData,
@@ -614,35 +614,38 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
614614
(req.socket[LastHopEncrypted] ? 'https' : 'http');
615615
req.path = req.url;
616616

617-
const tunnelUrlHost = (
618-
req.socket[LastTunnelAddress] &&
619-
!net.isIP(getDestination(req.protocol, req.socket[LastTunnelAddress]).hostname)
620-
)
621-
? normalizeHost(req.protocol, req.socket[LastTunnelAddress])
617+
const tunnelDestination = req.socket[LastTunnelAddress]
618+
? getDestination(req.protocol, req.socket[LastTunnelAddress])
622619
: undefined;
623620

624-
// If you explicitly tunnel to a hostname, that's the URL's hostname:
625-
const hostname = tunnelUrlHost
626-
// Otherwise, we infer based on headers: HTTP/2 or HTTP/1
627-
?? getHeaderValue(rawHeaders, ':authority')
628-
?? getHeaderValue(rawHeaders, 'host')
629-
?? req.socket[LastTunnelAddress] // Iff we have no hostname available at all
630-
?? `localhost:${this.port}`; // If you specify literally nothing, it's a direct request
621+
const isTunnelToIp = tunnelDestination && isIP(tunnelDestination.hostname);
631622

632-
// Destination may be either a hostname or an IP (unlike tunnel host)
633-
req.destination = getDestination(
634-
req.protocol,
635-
req.socket[LastTunnelAddress] ?? hostname
623+
const urlDestination = getDestination(req.protocol,
624+
(!isTunnelToIp
625+
? (
626+
req.socket[LastTunnelAddress] ?? // Tunnel domain name is preferred if available
627+
getHeaderValue(rawHeaders, ':authority') ??
628+
getHeaderValue(rawHeaders, 'host')
629+
)
630+
: (
631+
getHeaderValue(rawHeaders, ':authority') ??
632+
getHeaderValue(rawHeaders, 'host') ??
633+
req.socket[LastTunnelAddress] // We use the IP iff we have no hostname available at all
634+
))
635+
?? `localhost:${this.port}` // If you specify literally nothing, it's a direct request
636636
);
637637

638-
// If we don't have a port in the hostname, but we know the final destination port needs
639-
// specifying, then we do include it in the URL. Happens if you have an IP tunnel address
640-
// with a port, and then a port-less 'Host' header - not common.
641-
const host = !hostname.includes(':') && req.destination.port !== getDefaultPort(req.protocol)
642-
? `${hostname}:${req.destination.port}`
643-
: hostname;
644638

645-
const absoluteUrl = `${req.protocol}://${host}${req.path}`;
639+
// Actual destination always follows the tunnel - even if it's an IP
640+
req.destination = tunnelDestination
641+
?? urlDestination;
642+
643+
// URL port should always match the real port - even if (e.g) the Host header is lying.
644+
urlDestination.port = req.destination.port;
645+
646+
const absoluteUrl = `${req.protocol}://${
647+
normalizeHost(req.protocol, `${urlDestination.hostname}:${urlDestination.port}`)
648+
}${req.path}`;
646649

647650
if (!getHeaderValue(rawHeaders, ':path')) {
648651
(req as Mutable<ExtendedRawRequest>).url = new url.URL(absoluteUrl).toString();

src/util/ip-utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// These are rough tests for IPs: they exclude valid domain names,
2+
// but they don't strictly check IP formatting (that's fine - invalid
3+
// IPs will fail elsewhere - this is for intended-format checks).
4+
const IPv4_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
5+
const IPv6_REGEX = /^(?=.*[0-9a-fA-F])(?=.*:)[0-9a-fA-F:]{2,39}$/;
6+
7+
export const isIPv4Address = (ip: string) =>
8+
IPv4_REGEX.test(ip);
9+
10+
export const isIPv6Address = (ip: string) =>
11+
IPv6_REGEX.test(ip);
12+
13+
export const isIP = (ip: string) =>
14+
isIPv4Address(ip) || isIPv6Address(ip);
15+
16+
// We need to normalize ips some cases (especially comparisons), because the same ip may be reported
17+
// as ::ffff:127.0.0.1 and 127.0.0.1 on the two sides of the connection, for the same ip.
18+
export const normalizeIP = (ip: string | null | undefined) =>
19+
(ip && ip.startsWith('::ffff:'))
20+
? ip.slice('::ffff:'.length)
21+
: ip;
22+
23+
export const isLocalhostAddress = (host: string | null | undefined) =>
24+
!!host && ( // Null/undef are something else weird, but not localhost
25+
host === 'localhost' || // Most common
26+
host.endsWith('.localhost') ||
27+
host === '::1' || // IPv6
28+
normalizeIP(host)!.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) // 127.0.0.0/8 range
29+
);

src/util/socket-util.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
SocketMetadata
2020
} from './socket-extensions';
2121
import { getSocketMetadataTags } from './socket-metadata';
22+
import { normalizeIP } from './ip-utils';
2223

2324
// Test if a local port for a given interface (IPv4/6) is currently in use
2425
export async function isLocalPortActive(interfaceIp: '::1' | '127.0.0.1', port: number) {
@@ -49,20 +50,7 @@ export const isLocalIPv6Available = isNode
4950
)
5051
: true;
5152

52-
// We need to normalize ips some cases (especially comparisons), because the same ip may be reported
53-
// as ::ffff:127.0.0.1 and 127.0.0.1 on the two sides of the connection, for the same ip.
54-
export const normalizeIP = (ip: string | null | undefined) =>
55-
(ip && ip.startsWith('::ffff:'))
56-
? ip.slice('::ffff:'.length)
57-
: ip;
58-
59-
export const isLocalhostAddress = (host: string | null | undefined) =>
60-
!!host && ( // Null/undef are something else weird, but not localhost
61-
host === 'localhost' || // Most common
62-
host.endsWith('.localhost') ||
63-
host === '::1' || // IPv6
64-
normalizeIP(host)!.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) // 127.0.0.0/8 range
65-
);
53+
6654

6755
// Check whether an incoming socket is the other end of one of our outgoing sockets:
6856
export const isSocketLoop = (outgoingSockets: net.Socket[] | Set<net.Socket>, incomingSocket: net.Socket) =>

test/integration/proxying/socks-proxying.spec.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,19 @@ nodeOnly(() => {
110110
});
111111
});
112112

113-
it("should use the SOCKS destination IP over the Host header, but not in the URL", async () => {
113+
it("should use the SOCKS destination IP over the Host header, but not in the URL or passthrough events", async () => {
114114
const seenRequest = getDeferred<Request>();
115115
await server.on('request', (req) => seenRequest.resolve(req));
116116

117+
const passthroughEvent = getDeferred<any>();
118+
await server.on('rule-event', (event) => {
119+
if (event.eventType === 'passthrough-request-head') passthroughEvent.resolve(event.eventData);
120+
});
121+
117122
const socksSocket = await openSocksSocket(server, '127.0.0.1', remoteServer.port, { type: 5 });
118123
const response = await h1RequestOverSocket(socksSocket, remoteServer.url, {
119124
headers: {
120-
Host: "invalid.example" // This should be ignored - tunnel sets destination
125+
Host: "invalid.example:1234" // This should be ignored - tunnel sets destination
121126
}
122127
});
123128
expect(response.statusCode).to.equal(200);
@@ -131,6 +136,8 @@ nodeOnly(() => {
131136
hostname: '127.0.0.1',
132137
port: remoteServer.port
133138
});
139+
expect((await passthroughEvent).hostname).to.equal('invalid.example');
140+
expect((await passthroughEvent).port).to.equal(remoteServer.port.toString());
134141
});
135142

136143
});

0 commit comments

Comments
 (0)