From 93a909a60aaa8e2d954836f97f8ee1c7dfd93d30 Mon Sep 17 00:00:00 2001 From: Emmanuel Pire Date: Thu, 28 Mar 2024 14:53:07 +0100 Subject: [PATCH] Add option tlsIntercept --- src/mockttp.ts | 12 ++++++++ src/server/http-combo-server.ts | 21 +++++++++++-- test/integration/https.spec.ts | 54 +++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/mockttp.ts b/src/mockttp.ts index 420c6328e..5097ca0bc 100644 --- a/src/mockttp.ts +++ b/src/mockttp.ts @@ -696,6 +696,18 @@ export type MockttpHttpsOptions = CAOptions & { * here for additional configuration of this behaviour. */ tlsPassthrough?: Array<{ hostname: string }> + + /** + * A list of hostnames that should only be intercepted. + * + * When set, only connections to these hostnames will be intercepted, and all + * other connections will be passed through without interception. + * + * Each element in the list must be an object with a 'hostname' field for the + * hostname that should be matched. In future more options may be supported + * here for additional configuration of this behaviour. + */ + tlsIntercept?: Array<{ hostname: string }> }; export interface MockttpOptions { diff --git a/src/server/http-combo-server.ts b/src/server/http-combo-server.ts index aabfb16c1..71255c111 100644 --- a/src/server/http-combo-server.ts +++ b/src/server/http-combo-server.ts @@ -201,6 +201,7 @@ export async function createComboServer( analyzeAndMaybePassThroughTls( tlsServer, options.https.tlsPassthrough ?? [], + options.https.tlsIntercept ?? [], tlsPassthroughListener ); @@ -369,9 +370,14 @@ function copyTimingDetails>( function analyzeAndMaybePassThroughTls( server: tls.Server, passthroughList: Required['tlsPassthrough'], + interceptList: Required['tlsIntercept'], passthroughListener: (socket: net.Socket, address: string, port?: number) => void ) { - const hostnames = passthroughList.map(({ hostname }) => hostname); + if (passthroughList.length > 0 && interceptList.length > 0){ + throw new Error('Cannot use both tlsPassthrough and tlsIntercept at the same time.'); + } + const passThroughHostnames = passthroughList.map(({ hostname }) => hostname); + const interceptHostnames = interceptList.map(({ hostname }) => hostname); const tlsConnectionListener = server.listeners('connection')[0] as (socket: net.Socket) => {}; server.removeListener('connection', tlsConnectionListener); @@ -389,12 +395,21 @@ function analyzeAndMaybePassThroughTls( clientAlpn: helloData.alpnProtocols, ja3Fingerprint: calculateJa3FromFingerprintData(helloData.fingerprintData) }; + + if (interceptHostnames.length > 0 && connectHostname && !interceptHostnames.includes(connectHostname)) { + const upstreamPort = connectPort ? parseInt(connectPort, 10) : undefined; + passthroughListener(socket, connectHostname, upstreamPort); + return; // Do not continue with TLS + } else if (interceptHostnames.length > 0 && sniHostname && !interceptHostnames.includes(sniHostname)) { + passthroughListener(socket, sniHostname); // Can't guess the port - not included in SNI + return; // Do not continue with TLS + } - if (connectHostname && hostnames.includes(connectHostname)) { + if (connectHostname && passThroughHostnames.includes(connectHostname)) { const upstreamPort = connectPort ? parseInt(connectPort, 10) : undefined; passthroughListener(socket, connectHostname, upstreamPort); return; // Do not continue with TLS - } else if (sniHostname && hostnames.includes(sniHostname)) { + } else if (sniHostname && passThroughHostnames.includes(sniHostname)) { passthroughListener(socket, sniHostname); // Can't guess the port - not included in SNI return; // Do not continue with TLS } diff --git a/test/integration/https.spec.ts b/test/integration/https.spec.ts index f24b0e2b7..b60ef9281 100644 --- a/test/integration/https.spec.ts +++ b/test/integration/https.spec.ts @@ -252,5 +252,59 @@ describe("When configured for HTTPS", () => { }); }); + describe("with some hostnames included", () => { + let server = getLocal({ + https: { + keyPath: './test/fixtures/test-ca.key', + certPath: './test/fixtures/test-ca.pem', + tlsIntercept: [ + { hostname: 'wikipedia.org' } + ] + } + }); + + beforeEach(async () => { + await server.start(); + await server.forGet('/').thenReply(200, "Mock response"); + }); + + afterEach(async () => { + await server.stop() + }); + + it("handles matching HTTPS requests", async () => { + const response: http.IncomingMessage = await new Promise((resolve) => + https.get({ + host: 'localhost', + port: server.port, + servername: 'wikipedia.org', + headers: { 'Host': 'wikipedia.org' } + }).on('response', resolve) + ); + + expect(response.statusCode).to.equal(200); + const body = (await streamToBuffer(response)).toString(); + expect(body).to.equal("Mock response"); + }); + + it("skips the server for non-matching HTTPS requests", async function () { + this.retries(3); // Example.com can be unreliable + + const response: http.IncomingMessage = await new Promise((resolve, reject) => + https.get({ + host: 'localhost', + port: server.port, + servername: 'example.com', + headers: { 'Host': 'example.com' } + }).on('response', resolve).on('error', reject) + ); + + expect(response.statusCode).to.equal(200); + const body = (await streamToBuffer(response)).toString(); + expect(body).to.include( + "This domain is for use in illustrative examples in documents." + ); + }); + }); }); }); \ No newline at end of file