Skip to content

Commit 062f534

Browse files
committed
Add support for HTTP-over-SOCKS interception
This is disabled by default, but can be enabled with the new `socks` option to support incoming SOCKS connections on the same port as all other traffic.
1 parent 8ff797b commit 062f534

File tree

7 files changed

+427
-18
lines changed

7 files changed

+427
-18
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
"request": "^2.75.0",
142142
"request-promise-native": "^1.0.3",
143143
"rimraf": "^2.5.4",
144+
"socks": "^2.8.4",
144145
"source-map-support": "^0.5.3",
145146
"stream-browserify": "^3.0.0",
146147
"tmp-promise": "^1.0.3",
@@ -160,7 +161,7 @@
160161
"dependencies": {
161162
"@graphql-tools/schema": "^8.5.0",
162163
"@graphql-tools/utils": "^8.8.0",
163-
"@httptoolkit/httpolyglot": "^2.2.1",
164+
"@httptoolkit/httpolyglot": "^3.0.0",
164165
"@httptoolkit/subscriptions-transport-ws": "^0.11.2",
165166
"@httptoolkit/util": "^0.1.6",
166167
"@httptoolkit/websocket-stream": "^6.0.1",

src/mockttp.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -695,10 +695,10 @@ export type MockttpHttpsOptions = CAOptions & {
695695
* options will throw an error.
696696
*
697697
* Each element in this list must be an object with a 'hostname' field for the
698-
* hostname that should be matched. Wildcards are supported (following the
698+
* hostname that should be matched. Wildcards are supported (following the
699699
* [URLPattern specification](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)),
700700
* eg. `{hostname: '*.example.com'}`.
701-
*
701+
*
702702
* In future more options may be supported
703703
* here for additional configuration of this behaviour.
704704
*/
@@ -715,10 +715,10 @@ export type MockttpHttpsOptions = CAOptions & {
715715
* options will throw an error.
716716
*
717717
* Each element in this list must be an object with a 'hostname' field for the
718-
* hostname that should be matched. Wildcards are supported (following the
718+
* hostname that should be matched. Wildcards are supported (following the
719719
* [URLPattern specification](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)),
720720
* eg. `{hostname: '*.example.com'}`.
721-
*
721+
*
722722
* In future more options may be supported
723723
* here for additional configuration of this behaviour.
724724
*/
@@ -774,6 +774,15 @@ export interface MockttpOptions {
774774
*/
775775
http2?: true | 'fallback' | false;
776776

777+
/**
778+
* Should the server accept incoming SOCKS connections? Defaults to false.
779+
* If set to true, the server will listen for incoming SOCKS connections
780+
* on the same port as the HTTP server, unwrap received connections, and
781+
* handle them like any other incoming TCP connection (intercepting HTTP(S)
782+
* from within the SOCKS connection as normal).
783+
*/
784+
socks?: boolean;
785+
777786
/**
778787
* By default, requests that match no rules will receive an explanation of the
779788
* request & existing rules, followed by some suggested example Mockttp code

src/server/http-combo-server.ts

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as streams from 'stream';
99
import * as semver from 'semver';
1010
import { makeDestroyable, DestroyableServer } from 'destroyable-server';
1111
import * as httpolyglot from '@httptoolkit/httpolyglot';
12-
import { delay } from '@httptoolkit/util';
12+
import { delay, unreachableCheck } from '@httptoolkit/util';
1313
import {
1414
calculateJa3FromFingerprintData,
1515
calculateJa4FromHelloData,
@@ -27,6 +27,7 @@ import {
2727
buildSocketEventData
2828
} from '../util/socket-util';
2929
import { MockttpHttpsOptions } from '../mockttp';
30+
import { buildSocksServer, SocksTcpAddress } from './socks-server';
3031

3132
// Hardcore monkey-patching: force TLSSocket to link servername & remoteAddress to
3233
// sockets as soon as they're available, without waiting for the handshake to fully
@@ -53,10 +54,11 @@ const originalSocketInit = (<any>tls.TLSSocket.prototype)._init;
5354
};
5455
};
5556

56-
export type ComboServerOptions = {
57-
debug: boolean,
58-
https: MockttpHttpsOptions | undefined,
59-
http2: true | false | 'fallback'
57+
export interface ComboServerOptions {
58+
debug: boolean;
59+
https: MockttpHttpsOptions | undefined;
60+
http2: boolean | 'fallback';
61+
socks: boolean;
6062
};
6163

6264
// Takes an established TLS socket, calls the error listener if it's silently closed
@@ -147,9 +149,10 @@ export async function createComboServer(
147149
tlsPassthroughListener: (socket: net.Socket, address: string, port?: number) => void
148150
): Promise<DestroyableServer<net.Server>> {
149151
let server: net.Server;
150-
if (!options.https) {
151-
server = httpolyglot.createServer(requestListener);
152-
} else {
152+
let tlsServer: tls.Server | undefined = undefined;
153+
let socksServer: net.Server | undefined = undefined;
154+
155+
if (options.https) {
153156
const ca = await getCA(options.https);
154157
const defaultCert = ca.generateCertificate(options.https.defaultDomain ?? 'localhost');
155158

@@ -179,7 +182,7 @@ export async function createComboServer(
179182
ALPNProtocols: serverProtocolPreferences
180183
}
181184

182-
const tlsServer = tls.createServer({
185+
tlsServer = tls.createServer({
183186
key: defaultCert.key,
184187
cert: defaultCert.cert,
185188
ca: [defaultCert.ca],
@@ -208,10 +211,35 @@ export async function createComboServer(
208211
options.https.tlsInterceptOnly,
209212
tlsPassthroughListener
210213
);
214+
}
215+
216+
if (options.socks) {
217+
socksServer = buildSocksServer();
218+
socksServer.on('socks-tcp-connect', (socket: net.Socket, address: SocksTcpAddress) => {
219+
const addressString =
220+
address.type === 'ipv4'
221+
? `${address.ip}:${address.port}`
222+
: address.type === 'ipv6'
223+
? `[${address.ip}]:${address.port}`
224+
: address.type === 'hostname'
225+
? `${address.hostname}:${address.port}`
226+
: unreachableCheck(address)
227+
228+
if (options.debug) console.log(`Proxying SOCKS TCP connection to ${addressString}`);
229+
230+
socket.__timingInfo!.tunnelSetupTimestamp = now();
231+
socket.__lastHopConnectAddress = addressString;
211232

212-
server = httpolyglot.createServer(tlsServer, requestListener);
233+
// Put the socket back into the server, so we can handle the data within:
234+
server.emit('connection', socket);
235+
});
213236
}
214237

238+
server = httpolyglot.createServer({
239+
tls: tlsServer,
240+
socks: socksServer,
241+
}, requestListener);
242+
215243
// In Node v20, this option was added, rejecting all requests with no host header. While that's good, in
216244
// our case, we want to handle the garbage requests too, so we disable it:
217245
(server as any)._httpServer.requireHostHeader = false;
@@ -393,9 +421,22 @@ function analyzeAndMaybePassThroughTls(
393421
try {
394422
const helloData = await readTlsClientHello(socket);
395423

396-
const [connectHostname, connectPort] = socket.__lastHopConnectAddress?.split(':') ?? [];
397424
const sniHostname = helloData.serverName;
398425

426+
// SNI is a good clue for where the request is headed, but an explicit proxy address (via
427+
// CONNECT or SOCKS) is even better. Note that this may be a hostname or IPv4/6 address:
428+
let connectHostname: string | undefined;
429+
let connectPort: string | undefined;
430+
if (socket.__lastHopConnectAddress) {
431+
const lastColonIndex = socket.__lastHopConnectAddress.lastIndexOf(':');
432+
if (lastColonIndex !== -1) {
433+
connectHostname = socket.__lastHopConnectAddress.slice(0, lastColonIndex);
434+
connectPort = socket.__lastHopConnectAddress.slice(lastColonIndex + 1);
435+
} else {
436+
connectHostname = socket.__lastHopConnectAddress;
437+
}
438+
}
439+
399440
socket.__tlsMetadata = {
400441
sniHostname,
401442
connectHostname,

src/server/mockttp-server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
8686
private webSocketRuleSets: { [priority: number]: WebSocketRule[] } = {};
8787

8888
private httpsOptions: MockttpHttpsOptions | undefined;
89-
private isHttp2Enabled: true | false | 'fallback';
89+
private isHttp2Enabled: boolean | 'fallback';
90+
private socksEnabled: boolean;
9091
private maxBodySize: number;
9192

9293
private app: connect.Server;
@@ -105,6 +106,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
105106

106107
this.httpsOptions = options.https;
107108
this.isHttp2Enabled = options.http2 ?? 'fallback';
109+
this.socksEnabled = options.socks ?? false;
108110
this.maxBodySize = options.maxBodySize ?? Infinity;
109111
this.eventEmitter = new EventEmitter();
110112

@@ -130,6 +132,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
130132
debug: this.debug,
131133
https: this.httpsOptions,
132134
http2: this.isHttp2Enabled,
135+
socks: this.socksEnabled
133136
}, this.app, this.announceTlsErrorAsync.bind(this), this.passthroughSocket.bind(this));
134137

135138
// We use a mutex here to avoid contention on ports with parallel setup

0 commit comments

Comments
 (0)