Skip to content

Commit 6139a07

Browse files
committed
Add unknown protocol tunnelling & raw-passthrough-opened/closed events
1 parent 5a2dc06 commit 6139a07

14 files changed

+587
-161
lines changed

src/admin/mockttp-admin-model.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ const TLS_PASSTHROUGH_OPENED_TOPIC = 'tls-passthrough-opened';
3232
const TLS_PASSTHROUGH_CLOSED_TOPIC = 'tls-passthrough-closed';
3333
const TLS_CLIENT_ERROR_TOPIC = 'tls-client-error';
3434
const CLIENT_ERROR_TOPIC = 'client-error';
35+
const RAW_PASSTHROUGH_OPENED_TOPIC = 'raw-passthrough-opened';
36+
const RAW_PASSTHROUGH_CLOSED_TOPIC = 'raw-passthrough-closed';
3537
const RULE_EVENT_TOPIC = 'rule-event';
3638

3739
async function buildMockedEndpointData(endpoint: ServerMockedEndpoint): Promise<MockedEndpointData> {
@@ -132,6 +134,18 @@ export function buildAdminServerModel(
132134
})
133135
});
134136

137+
mockServer.on('raw-passthrough-opened', (evt) => {
138+
pubsub.publish(RAW_PASSTHROUGH_OPENED_TOPIC, {
139+
rawPassthroughOpened: evt
140+
})
141+
});
142+
143+
mockServer.on('raw-passthrough-closed', (evt) => {
144+
pubsub.publish(RAW_PASSTHROUGH_CLOSED_TOPIC, {
145+
rawPassthroughClosed: evt
146+
})
147+
});
148+
135149
mockServer.on('rule-event', (evt) => {
136150
pubsub.publish(RULE_EVENT_TOPIC, {
137151
ruleEvent: evt
@@ -237,6 +251,12 @@ export function buildAdminServerModel(
237251
failedClientRequest: {
238252
subscribe: () => pubsub.asyncIterator(CLIENT_ERROR_TOPIC)
239253
},
254+
rawPassthroughOpened: {
255+
subscribe: () => pubsub.asyncIterator(RAW_PASSTHROUGH_OPENED_TOPIC)
256+
},
257+
rawPassthroughClosed: {
258+
subscribe: () => pubsub.asyncIterator(RAW_PASSTHROUGH_CLOSED_TOPIC)
259+
},
240260
ruleEvent: {
241261
subscribe: () => pubsub.asyncIterator(RULE_EVENT_TOPIC)
242262
}

src/admin/mockttp-schema.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export const MockttpSchema = gql`
3131
tlsPassthroughOpened: TlsPassthroughEvent!
3232
tlsPassthroughClosed: TlsPassthroughEvent!
3333
failedTlsRequest: TlsHandshakeFailure!
34+
rawPassthroughOpened: RawPassthroughEvent!
35+
rawPassthroughClosed: RawPassthroughEvent!
3436
failedClientRequest: ClientError!
3537
ruleEvent: RuleEvent!
3638
}
@@ -60,6 +62,8 @@ export const MockttpSchema = gql`
6062
6163
type TlsPassthroughEvent {
6264
id: String!
65+
66+
upstreamHost: String
6367
upstreamPort: Int!
6468
6569
hostname: String
@@ -114,6 +118,18 @@ export const MockttpSchema = gql`
114118
remotePort: Int
115119
}
116120
121+
type RawPassthroughEvent {
122+
id: String!
123+
124+
upstreamHost: String!
125+
upstreamPort: Int!
126+
127+
remoteIpAddress: String!
128+
remotePort: Int!
129+
tags: [String!]!
130+
timingEvents: Json!
131+
}
132+
117133
type RuleEvent {
118134
requestId: ID!
119135
ruleId: ID!

src/client/mockttp-admin-request-builder.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,8 @@ export class MockttpAdminRequestBuilder {
379379
'tls-passthrough-opened': gql`subscription OnTlsPassthroughOpened {
380380
tlsPassthroughOpened {
381381
id
382+
383+
${this.schema.asOptionalField('TlsPassthroughEvent', 'upstreamHost')}
382384
upstreamPort
383385
384386
hostname
@@ -392,6 +394,8 @@ export class MockttpAdminRequestBuilder {
392394
'tls-passthrough-closed': gql`subscription OnTlsPassthroughClosed {
393395
tlsPassthroughClosed {
394396
id
397+
398+
${this.schema.asOptionalField('TlsPassthroughEvent', 'upstreamHost')}
395399
upstreamPort
396400
397401
hostname
@@ -451,6 +455,32 @@ export class MockttpAdminRequestBuilder {
451455
}
452456
}
453457
}`,
458+
'raw-passthrough-opened': gql`subscription OnRawPassthroughOpened {
459+
rawPassthroughOpened {
460+
id
461+
462+
upstreamHost
463+
upstreamPort
464+
465+
remoteIpAddress
466+
remotePort
467+
tags
468+
timingEvents
469+
}
470+
}`,
471+
'raw-passthrough-closed': gql`subscription OnRawPassthroughClosed {
472+
rawPassthroughClosed {
473+
id
474+
475+
upstreamHost
476+
upstreamPort
477+
478+
remoteIpAddress
479+
remotePort
480+
tags
481+
timingEvents
482+
}
483+
}`,
454484
'rule-event': gql`subscription OnRuleEvent {
455485
ruleEvent {
456486
requestId

src/mockttp.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
WebSocketMessage,
2121
WebSocketClose,
2222
AbortedRequest,
23-
RuleEvent
23+
RuleEvent,
24+
RawPassthroughEvent
2425
} from "./types";
2526
import type { RequestRuleData } from "./rules/requests/request-rule";
2627
import type { WebSocketRuleData } from "./rules/websockets/websocket-rule";
@@ -545,6 +546,30 @@ export interface Mockttp {
545546
*/
546547
on(event: 'client-error', callback: (error: ClientError) => void): Promise<void>;
547548

549+
/**
550+
* Subscribe to hear about connections that are passed through the proxy without
551+
* interception, due to the `passthrough` option.
552+
*
553+
* This is separate to TLS passthrough: raw passthrough happens automatically
554+
* before any TLS handshake is received (so includes no TLS data, and may use any
555+
* protocol) generally because the protocol on the connection is not HTTP. TLS
556+
* passthrough happens after the TLS client hello has been received, only if it
557+
* has matched a rule defined in the tlsPassthrough options (e.g. a specific
558+
* hostname).
559+
*
560+
* @category Events
561+
*/
562+
on(event: 'raw-passthrough-opened', callback: (req: RawPassthroughEvent) => void): Promise<void>;
563+
564+
/**
565+
* Subscribe to hear about close of connections that are passed through the proxy
566+
* without interception, due to the `passthrough` option. See `raw-passthrough-opened`
567+
* for more details.
568+
*
569+
* @category Events
570+
*/
571+
on(event: 'raw-passthrough-closed', callback: (req: RawPassthroughEvent) => void): Promise<void>;
572+
548573
/**
549574
* Some rules may emit events with metadata about request processing. For example,
550575
* passthrough rules may emit events about upstream server interactions.
@@ -783,6 +808,29 @@ export interface MockttpOptions {
783808
*/
784809
socks?: boolean;
785810

811+
/**
812+
* An array of rules for traffic that should be passed through the proxy
813+
* immediately, without interception or modification.
814+
*
815+
* This is subtly different to TLS passthrough/interceptOnly, which only
816+
* apply to TLS connections, and only after the TLS client hello has been
817+
* received and found to match a rule.
818+
*
819+
* For now, the only rule here is 'unknown-protocol', which enables
820+
* passthrough of all unknown protocols (i.e. traffic that is definitely
821+
* not HTTP, HTTP/2, WebSocket, or SOCKS traffic) which are received on
822+
* a proxy connection (a connection carrying end-destination information,
823+
* such as SOCKS - direct connections of unknown data without any final
824+
* destination information from a preceeding tunnel cannot be passed
825+
* through).
826+
*
827+
* Unknown protocol connections that cannot be passed through (because
828+
* this rule is not enabled, or because they are not proxied with a
829+
* destination specified) will be closed with a 400 Bad Request HTTP
830+
* response like any other client HTTP error.
831+
*/
832+
passthrough?: Array<'unknown-protocol'>;
833+
786834
/**
787835
* By default, requests that match no rules will receive an explanation of the
788836
* request & existing rules, followed by some suggested example Mockttp code
@@ -834,6 +882,8 @@ export type SubscribableEvent =
834882
| 'tls-passthrough-closed'
835883
| 'tls-client-error'
836884
| 'client-error'
885+
| 'raw-passthrough-opened'
886+
| 'raw-passthrough-closed'
837887
| 'rule-event';
838888

839889
/**

src/server/http-combo-server.ts

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import net = require('net');
44
import tls = require('tls');
55
import http = require('http');
66
import http2 = require('http2');
7-
import * as streams from 'stream';
87

98
import * as semver from 'semver';
109
import { makeDestroyable, DestroyableServer } from 'destroyable-server';
@@ -24,15 +23,17 @@ import { shouldPassThrough } from '../util/server-utils';
2423
import {
2524
getParentSocket,
2625
buildSocketTimingInfo,
27-
buildSocketEventData,
26+
buildTlsSocketEventData,
2827
SocketIsh,
2928
InitialRemoteAddress,
3029
InitialRemotePort,
3130
SocketTimingInfo,
3231
LastTunnelAddress,
3332
LastHopEncrypted,
3433
TlsMetadata,
35-
TlsSetupCompleted
34+
TlsSetupCompleted,
35+
getAddressAndPort,
36+
resetOrDestroy
3637
} from '../util/socket-util';
3738
import { MockttpHttpsOptions } from '../mockttp';
3839
import { buildSocksServer, SocksTcpAddress } from './socks-server';
@@ -62,13 +63,6 @@ const originalSocketInit = (<any>tls.TLSSocket.prototype)._init;
6263
};
6364
};
6465

65-
export interface ComboServerOptions {
66-
debug: boolean;
67-
https: MockttpHttpsOptions | undefined;
68-
http2: boolean | 'fallback';
69-
socks: boolean;
70-
};
71-
7266
// Takes an established TLS socket, calls the error listener if it's silently closed
7367
function ifTlsDropped(socket: tls.TLSSocket, errorCallback: () => void) {
7468
new Promise((resolve, reject) => {
@@ -139,26 +133,35 @@ function buildTlsError(
139133
socket: tls.TLSSocket,
140134
cause: TlsHandshakeFailure['failureCause']
141135
): TlsHandshakeFailure {
142-
const eventData = buildSocketEventData(socket) as TlsHandshakeFailure;
136+
const eventData = buildTlsSocketEventData(socket) as TlsHandshakeFailure;
143137

144138
eventData.failureCause = cause;
145139
eventData.timingEvents.failureTimestamp = now();
146140

147141
return eventData;
148142
}
149143

144+
export interface ComboServerOptions {
145+
debug: boolean;
146+
https: MockttpHttpsOptions | undefined;
147+
http2: boolean | 'fallback';
148+
socks: boolean;
149+
passthroughUnknownProtocols: boolean;
150+
151+
requestListener: (req: http.IncomingMessage, res: http.ServerResponse) => void;
152+
tlsClientErrorListener: (socket: tls.TLSSocket, req: TlsHandshakeFailure) => void;
153+
tlsPassthroughListener: (socket: net.Socket, address: string, port?: number) => void;
154+
rawPassthroughListener: (socket: net.Socket, address: string, port?: number) => void;
155+
};
156+
150157
// The low-level server that handles all the sockets & TLS. The server will correctly call the
151158
// given handler for both HTTP & HTTPS direct connections, or connections when used as an
152159
// either HTTP or HTTPS proxy, all on the same port.
153-
export async function createComboServer(
154-
options: ComboServerOptions,
155-
requestListener: (req: http.IncomingMessage, res: http.ServerResponse) => void,
156-
tlsClientErrorListener: (socket: tls.TLSSocket, req: TlsHandshakeFailure) => void,
157-
tlsPassthroughListener: (socket: net.Socket, address: string, port?: number) => void
158-
): Promise<DestroyableServer<net.Server>> {
160+
export async function createComboServer(options: ComboServerOptions): Promise<DestroyableServer<net.Server>> {
159161
let server: net.Server;
160162
let tlsServer: tls.Server | undefined = undefined;
161163
let socksServer: net.Server | undefined = undefined;
164+
let unknownProtocolServer: net.Server | undefined = undefined;
162165

163166
if (options.https) {
164167
const ca = await getCA(options.https);
@@ -217,7 +220,7 @@ export async function createComboServer(
217220
tlsServer,
218221
options.https.tlsPassthrough,
219222
options.https.tlsInterceptOnly,
220-
tlsPassthroughListener
223+
options.tlsPassthroughListener
221224
);
222225
}
223226

@@ -243,10 +246,29 @@ export async function createComboServer(
243246
});
244247
}
245248

249+
if (options.passthroughUnknownProtocols) {
250+
unknownProtocolServer = net.createServer((socket) => {
251+
const destination = socket[LastTunnelAddress];
252+
if (!destination) {
253+
server.emit('clientError', new Error('Unknown protocol without destination'), socket);
254+
return;
255+
}
256+
257+
const [host, port] = getAddressAndPort(destination);
258+
if (!port) { // Both CONNECT & SOCKS require a port, so this shouldn't happen
259+
server.emit('clientError', new Error('Unknown protocol without destination port'), socket);
260+
return;
261+
}
262+
263+
options.rawPassthroughListener(socket, host, port);
264+
});
265+
}
266+
246267
server = httpolyglot.createServer({
247268
tls: tlsServer,
248269
socks: socksServer,
249-
}, requestListener);
270+
unknownProtocol: unknownProtocolServer
271+
}, options.requestListener);
250272

251273
// In Node v20, this option was added, rejecting all requests with no host header. While that's good, in
252274
// our case, we want to handle the garbage requests too, so we disable it:
@@ -282,7 +304,7 @@ export async function createComboServer(
282304

283305
socket[LastHopEncrypted] = true;
284306
ifTlsDropped(socket, () => {
285-
tlsClientErrorListener(socket, buildTlsError(socket, 'closed'));
307+
options.tlsClientErrorListener(socket, buildTlsError(socket, 'closed'));
286308
});
287309
});
288310

@@ -295,7 +317,7 @@ export async function createComboServer(
295317
});
296318

297319
server.on('tlsClientError', (error: Error, socket: tls.TLSSocket) => {
298-
tlsClientErrorListener(socket, buildTlsError(socket, getCauseFromError(error)));
320+
options.tlsClientErrorListener(socket, buildTlsError(socket, getCauseFromError(error)));
299321
});
300322

301323
// If the server receives a HTTP/HTTPS CONNECT request, Pretend to tunnel, then just re-handle:
@@ -431,30 +453,23 @@ function analyzeAndMaybePassThroughTls(
431453

432454
// SNI is a good clue for where the request is headed, but an explicit proxy address (via
433455
// CONNECT or SOCKS) is even better. Note that this may be a hostname or IPv4/6 address:
434-
let connectHostname: string | undefined;
435-
let connectPort: string | undefined;
456+
let upstreamHostname: string | undefined;
457+
let upstreamPort: number | undefined;
436458
if (socket[LastTunnelAddress]) {
437-
const lastColonIndex = socket[LastTunnelAddress].lastIndexOf(':');
438-
if (lastColonIndex !== -1) {
439-
connectHostname = socket[LastTunnelAddress].slice(0, lastColonIndex);
440-
connectPort = socket[LastTunnelAddress].slice(lastColonIndex + 1);
441-
} else {
442-
connectHostname = socket[LastTunnelAddress];
443-
}
459+
([upstreamHostname, upstreamPort] = getAddressAndPort(socket[LastTunnelAddress]));
444460
}
445461

446462
socket[TlsMetadata] = {
447463
sniHostname,
448-
connectHostname,
449-
connectPort,
464+
connectHostname: upstreamHostname,
465+
connectPort: upstreamPort?.toString(),
450466
clientAlpn: helloData.alpnProtocols,
451467
ja3Fingerprint: calculateJa3FromFingerprintData(helloData.fingerprintData),
452468
ja4Fingerprint: calculateJa4FromHelloData(helloData)
453469
};
454470

455-
if (shouldPassThrough(connectHostname, passThroughPatterns, interceptOnlyPatterns)) {
456-
const upstreamPort = connectPort ? parseInt(connectPort, 10) : undefined;
457-
passthroughListener(socket, connectHostname, upstreamPort);
471+
if (shouldPassThrough(upstreamHostname, passThroughPatterns, interceptOnlyPatterns)) {
472+
passthroughListener(socket, upstreamHostname, upstreamPort);
458473
return; // Do not continue with TLS
459474
} else if (shouldPassThrough(sniHostname, passThroughPatterns, interceptOnlyPatterns)) {
460475
passthroughListener(socket, sniHostname!); // Can't guess the port - not included in SNI

0 commit comments

Comments
 (0)