diff --git a/src/mockttp.ts b/src/mockttp.ts index 420c6328e..fa1b1b1d5 100644 --- a/src/mockttp.ts +++ b/src/mockttp.ts @@ -691,11 +691,30 @@ export type MockttpHttpsOptions = CAOptions & { * sent raw to the upstream hostname, without handling TLS within Mockttp (i.e. * with no TLS interception performed). * - * Each element in the list must be an object with a 'hostname' field for the + * This option is mutually exclusive with `tlsInterceptOnly` and setting both + * options will throw an error. + * + * Each element in this 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. + */ + tlsPassthrough?: Array<{ hostname: string }>; + + /** + * A limited list of the only hostnames whose TLS should be intercepted. + * + * This is the opposite of `tlsPassthrough`. When set, only connections + * to these hostnames will be intercepted, and all other TLS connections will + * be passed through without interception. + * + * This option is mutually exclusive with `tlsPassthrough` and setting both + * options will throw an error. + * + * Each element in this 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. */ - tlsPassthrough?: Array<{ hostname: string }> + tlsInterceptOnly?: Array<{ hostname: string }>; }; export interface MockttpOptions { diff --git a/src/server/http-combo-server.ts b/src/server/http-combo-server.ts index aabfb16c1..bfb1a6f42 100644 --- a/src/server/http-combo-server.ts +++ b/src/server/http-combo-server.ts @@ -200,7 +200,8 @@ export async function createComboServer( analyzeAndMaybePassThroughTls( tlsServer, - options.https.tlsPassthrough ?? [], + options.https.tlsPassthrough, + options.https.tlsInterceptOnly, tlsPassthroughListener ); @@ -362,16 +363,21 @@ function copyTimingDetails>( } /** - * Takes a tls passthrough list (may be empty), and reconfigures a given TLS server so that all + * Takes tls passthrough configuration (may be empty) and reconfigures a given TLS server so that all * client hellos are parsed, matching requests are passed to the given passthrough listener (without * continuing setup) and client hello metadata is attached to all sockets. */ function analyzeAndMaybePassThroughTls( server: tls.Server, - passthroughList: Required['tlsPassthrough'], + passthroughList: Required['tlsPassthrough'] | undefined, + interceptOnlyList: Required['tlsInterceptOnly'] | undefined, passthroughListener: (socket: net.Socket, address: string, port?: number) => void ) { - const hostnames = passthroughList.map(({ hostname }) => hostname); + if (passthroughList && interceptOnlyList){ + throw new Error('Cannot use both tlsPassthrough and tlsInterceptOnly options at the same time.'); + } + const passThroughHostnames = passthroughList?.map(({ hostname }) => hostname) ?? []; + const interceptOnlyHostnames = interceptOnlyList?.map(({ hostname }) => hostname); const tlsConnectionListener = server.listeners('connection')[0] as (socket: net.Socket) => {}; server.removeListener('connection', tlsConnectionListener); @@ -390,12 +396,12 @@ function analyzeAndMaybePassThroughTls( ja3Fingerprint: calculateJa3FromFingerprintData(helloData.fingerprintData) }; - if (connectHostname && hostnames.includes(connectHostname)) { + if (shouldPassThrough(connectHostname, passThroughHostnames, interceptOnlyHostnames)) { const upstreamPort = connectPort ? parseInt(connectPort, 10) : undefined; passthroughListener(socket, connectHostname, upstreamPort); return; // Do not continue with TLS - } else if (sniHostname && hostnames.includes(sniHostname)) { - passthroughListener(socket, sniHostname); // Can't guess the port - not included in SNI + } else if (shouldPassThrough(sniHostname, passThroughHostnames, interceptOnlyHostnames)) { + passthroughListener(socket, sniHostname!); // Can't guess the port - not included in SNI return; // Do not continue with TLS } } catch (e) { @@ -409,4 +415,19 @@ function analyzeAndMaybePassThroughTls( // Didn't match a passthrough hostname - continue with TLS setup tlsConnectionListener.call(server, socket); }); +} + +function shouldPassThrough( + hostname: string | undefined, + // Only one of these two should have values (validated above): + passThroughHostnames: string[], + interceptOnlyHostnames: string[] | undefined +): boolean { + if (!hostname) return false; + + if (interceptOnlyHostnames) { + return !interceptOnlyHostnames.includes(hostname); + } + + return passThroughHostnames.includes(hostname); } \ No newline at end of file diff --git a/test/integration/https.spec.ts b/test/integration/https.spec.ts index f24b0e2b7..d63e138da 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', + tlsInterceptOnly: [ + { 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