diff --git a/package.json b/package.json index 1cdba92a9..92379ffa4 100644 --- a/package.json +++ b/package.json @@ -197,6 +197,7 @@ "semver": "^7.5.3", "socks-proxy-agent": "^7.0.0", "typed-error": "^3.0.2", + "urlpattern-polyfill": "^8.0.0", "uuid": "^8.3.2", "ws": "^8.8.0" } diff --git a/src/mockttp.ts b/src/mockttp.ts index fa1b1b1d5..9e32e7b78 100644 --- a/src/mockttp.ts +++ b/src/mockttp.ts @@ -695,7 +695,11 @@ export type MockttpHttpsOptions = CAOptions & { * 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 + * hostname that should be matched. Wildcards are supported (following the + * [URLPattern specification](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)), + * eg. `{hostname: '*.example.com'}`. + * + * In future more options may be supported * here for additional configuration of this behaviour. */ tlsPassthrough?: Array<{ hostname: string }>; @@ -711,7 +715,11 @@ export type MockttpHttpsOptions = CAOptions & { * 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 + * hostname that should be matched. Wildcards are supported (following the + * [URLPattern specification](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)), + * eg. `{hostname: '*.example.com'}`. + * + * In future more options may be supported * here for additional configuration of this behaviour. */ tlsInterceptOnly?: Array<{ hostname: string }>; diff --git a/src/server/http-combo-server.ts b/src/server/http-combo-server.ts index 6512e33ac..4dfa3d0f9 100644 --- a/src/server/http-combo-server.ts +++ b/src/server/http-combo-server.ts @@ -14,10 +14,12 @@ import { NonTlsError, readTlsClientHello } from 'read-tls-client-hello'; +import { URLPattern } from "urlpattern-polyfill"; import { TlsHandshakeFailure } from '../types'; import { getCA } from '../util/tls'; import { delay } from '../util/util'; +import { shouldPassThrough } from '../util/server-utils'; import { getParentSocket, buildSocketTimingInfo, @@ -380,8 +382,8 @@ function analyzeAndMaybePassThroughTls( 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 passThroughPatterns = passthroughList?.map(({ hostname }) => new URLPattern(`https://${hostname}`)) ?? []; + const interceptOnlyPatterns = interceptOnlyList?.map(({ hostname }) => new URLPattern(`https://${hostname}`)); const tlsConnectionListener = server.listeners('connection')[0] as (socket: net.Socket) => {}; server.removeListener('connection', tlsConnectionListener); @@ -400,11 +402,11 @@ function analyzeAndMaybePassThroughTls( ja3Fingerprint: calculateJa3FromFingerprintData(helloData.fingerprintData) }; - if (shouldPassThrough(connectHostname, passThroughHostnames, interceptOnlyHostnames)) { + if (shouldPassThrough(connectHostname, passThroughPatterns, interceptOnlyPatterns)) { const upstreamPort = connectPort ? parseInt(connectPort, 10) : undefined; passthroughListener(socket, connectHostname, upstreamPort); return; // Do not continue with TLS - } else if (shouldPassThrough(sniHostname, passThroughHostnames, interceptOnlyHostnames)) { + } else if (shouldPassThrough(sniHostname, passThroughPatterns, interceptOnlyPatterns)) { passthroughListener(socket, sniHostname!); // Can't guess the port - not included in SNI return; // Do not continue with TLS } @@ -420,18 +422,3 @@ function analyzeAndMaybePassThroughTls( 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/src/util/server-utils.ts b/src/util/server-utils.ts new file mode 100644 index 000000000..4447e663e --- /dev/null +++ b/src/util/server-utils.ts @@ -0,0 +1,18 @@ +export function shouldPassThrough( + hostname: string | undefined, + // Only one of these two should have values (validated above): + passThroughPatterns: URLPattern[], + interceptOnlyPatterns: URLPattern[] | undefined +): boolean { + if (!hostname) return false; + + if (interceptOnlyPatterns) { + return !interceptOnlyPatterns.some((pattern) => + pattern.test(`https://${hostname}`) + ); + } + + return passThroughPatterns.some((pattern) => + pattern.test(`https://${hostname}`) + ); +} diff --git a/test/integration/https.spec.ts b/test/integration/https.spec.ts index d63e138da..8644cf12f 100644 --- a/test/integration/https.spec.ts +++ b/test/integration/https.spec.ts @@ -252,6 +252,61 @@ describe("When configured for HTTPS", () => { }); }); + describe("with wildcards hostnames excluded", () => { + let server = getLocal({ + https: { + keyPath: './test/fixtures/test-ca.key', + certPath: './test/fixtures/test-ca.pem', + tlsPassthrough: [ + { hostname: '*.com' } + ] + } + }); + + 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." + ); + }); + }); + describe("with some hostnames included", () => { let server = getLocal({ https: { @@ -306,5 +361,60 @@ describe("When configured for HTTPS", () => { ); }); }); + + describe("with wildcards hostnames included", () => { + let server = getLocal({ + https: { + keyPath: './test/fixtures/test-ca.key', + certPath: './test/fixtures/test-ca.pem', + tlsInterceptOnly: [ + { hostname: '*.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 diff --git a/test/server-utils.spec.ts b/test/server-utils.spec.ts new file mode 100644 index 000000000..0b789648a --- /dev/null +++ b/test/server-utils.spec.ts @@ -0,0 +1,77 @@ +import { URLPattern } from "urlpattern-polyfill"; +import { expect } from "./test-utils"; +import { shouldPassThrough } from "../src/util/server-utils"; + +describe("shouldPassThrough", () => { + it("should return false when passThroughHostnames is empty and interceptOnlyHostnames is undefined", async () => { + const should = shouldPassThrough("example.org", [], undefined); + expect(should).to.be.false; + }); + + it("should return true when both lists empty", async () => { + const should = shouldPassThrough("example.org", [], []); + expect(should).to.be.true; + }); + + it("should return false when hostname is falsy", () => { + const should = shouldPassThrough("", [], []); + expect(should).to.be.false; + }); + + describe("passThroughHostnames", () => { + it("should return true when hostname is in passThroughHostnames", () => { + const should = shouldPassThrough( + "example.org", + [new URLPattern("https://example.org")], + undefined + ); + expect(should).to.be.true; + }); + + it("should return false when hostname is not in passThroughHostnames", () => { + const should = shouldPassThrough( + "example.org", + [new URLPattern("https://example.com")], + undefined + ); + expect(should).to.be.false; + }); + + it("should return true when hostname match a wildcard", () => { + const should = shouldPassThrough( + "example.org", + [new URLPattern("https://*.org")], + undefined + ); + expect(should).to.be.true; + }); + }); + describe("interceptOnlyHostnames", () => { + it("should return false when hostname is in interceptOnlyHostnames", () => { + const should = shouldPassThrough( + "example.org", + [], + [new URLPattern("https://example.org")] + ); + expect(should).to.be.false; + }); + + it("should return true when hostname is not in interceptOnlyHostnames", () => { + const should = shouldPassThrough( + "example.org", + [], + [new URLPattern("https://example.com")] + ); + expect(should).to.be.true; + }); + + it("should return false when hostname match a wildcard", () => { + const should = shouldPassThrough( + "example.org", + [], + [new URLPattern("https://*.org")] + ); + expect(should).to.be.false; + }); + }); +});