Skip to content

Commit b916796

Browse files
authored
Merge pull request #171 from lipsumar/add-wildcard-support-for-passthrough
Add wildcard support for passthrough
2 parents 235a512 + 6e64526 commit b916796

File tree

6 files changed

+222
-21
lines changed

6 files changed

+222
-21
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@
197197
"semver": "^7.5.3",
198198
"socks-proxy-agent": "^7.0.0",
199199
"typed-error": "^3.0.2",
200+
"urlpattern-polyfill": "^8.0.0",
200201
"uuid": "^8.3.2",
201202
"ws": "^8.8.0"
202203
}

src/mockttp.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,11 @@ 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. In future more options may be supported
698+
* hostname that should be matched. Wildcards are supported (following the
699+
* [URLPattern specification](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)),
700+
* eg. `{hostname: '*.example.com'}`.
701+
*
702+
* In future more options may be supported
699703
* here for additional configuration of this behaviour.
700704
*/
701705
tlsPassthrough?: Array<{ hostname: string }>;
@@ -711,7 +715,11 @@ export type MockttpHttpsOptions = CAOptions & {
711715
* options will throw an error.
712716
*
713717
* Each element in this list must be an object with a 'hostname' field for the
714-
* hostname that should be matched. In future more options may be supported
718+
* hostname that should be matched. Wildcards are supported (following the
719+
* [URLPattern specification](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)),
720+
* eg. `{hostname: '*.example.com'}`.
721+
*
722+
* In future more options may be supported
715723
* here for additional configuration of this behaviour.
716724
*/
717725
tlsInterceptOnly?: Array<{ hostname: string }>;

src/server/http-combo-server.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import {
1414
NonTlsError,
1515
readTlsClientHello
1616
} from 'read-tls-client-hello';
17+
import { URLPattern } from "urlpattern-polyfill";
1718

1819
import { TlsHandshakeFailure } from '../types';
1920
import { getCA } from '../util/tls';
2021
import { delay } from '../util/util';
22+
import { shouldPassThrough } from '../util/server-utils';
2123
import {
2224
getParentSocket,
2325
buildSocketTimingInfo,
@@ -380,8 +382,8 @@ function analyzeAndMaybePassThroughTls(
380382
if (passthroughList && interceptOnlyList){
381383
throw new Error('Cannot use both tlsPassthrough and tlsInterceptOnly options at the same time.');
382384
}
383-
const passThroughHostnames = passthroughList?.map(({ hostname }) => hostname) ?? [];
384-
const interceptOnlyHostnames = interceptOnlyList?.map(({ hostname }) => hostname);
385+
const passThroughPatterns = passthroughList?.map(({ hostname }) => new URLPattern(`https://${hostname}`)) ?? [];
386+
const interceptOnlyPatterns = interceptOnlyList?.map(({ hostname }) => new URLPattern(`https://${hostname}`));
385387

386388
const tlsConnectionListener = server.listeners('connection')[0] as (socket: net.Socket) => {};
387389
server.removeListener('connection', tlsConnectionListener);
@@ -400,11 +402,11 @@ function analyzeAndMaybePassThroughTls(
400402
ja3Fingerprint: calculateJa3FromFingerprintData(helloData.fingerprintData)
401403
};
402404

403-
if (shouldPassThrough(connectHostname, passThroughHostnames, interceptOnlyHostnames)) {
405+
if (shouldPassThrough(connectHostname, passThroughPatterns, interceptOnlyPatterns)) {
404406
const upstreamPort = connectPort ? parseInt(connectPort, 10) : undefined;
405407
passthroughListener(socket, connectHostname, upstreamPort);
406408
return; // Do not continue with TLS
407-
} else if (shouldPassThrough(sniHostname, passThroughHostnames, interceptOnlyHostnames)) {
409+
} else if (shouldPassThrough(sniHostname, passThroughPatterns, interceptOnlyPatterns)) {
408410
passthroughListener(socket, sniHostname!); // Can't guess the port - not included in SNI
409411
return; // Do not continue with TLS
410412
}
@@ -420,18 +422,3 @@ function analyzeAndMaybePassThroughTls(
420422
tlsConnectionListener.call(server, socket);
421423
});
422424
}
423-
424-
function shouldPassThrough(
425-
hostname: string | undefined,
426-
// Only one of these two should have values (validated above):
427-
passThroughHostnames: string[],
428-
interceptOnlyHostnames: string[] | undefined
429-
): boolean {
430-
if (!hostname) return false;
431-
432-
if (interceptOnlyHostnames) {
433-
return !interceptOnlyHostnames.includes(hostname);
434-
}
435-
436-
return passThroughHostnames.includes(hostname);
437-
}

src/util/server-utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export function shouldPassThrough(
2+
hostname: string | undefined,
3+
// Only one of these two should have values (validated above):
4+
passThroughPatterns: URLPattern[],
5+
interceptOnlyPatterns: URLPattern[] | undefined
6+
): boolean {
7+
if (!hostname) return false;
8+
9+
if (interceptOnlyPatterns) {
10+
return !interceptOnlyPatterns.some((pattern) =>
11+
pattern.test(`https://${hostname}`)
12+
);
13+
}
14+
15+
return passThroughPatterns.some((pattern) =>
16+
pattern.test(`https://${hostname}`)
17+
);
18+
}

test/integration/https.spec.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,61 @@ describe("When configured for HTTPS", () => {
252252
});
253253
});
254254

255+
describe("with wildcards hostnames excluded", () => {
256+
let server = getLocal({
257+
https: {
258+
keyPath: './test/fixtures/test-ca.key',
259+
certPath: './test/fixtures/test-ca.pem',
260+
tlsPassthrough: [
261+
{ hostname: '*.com' }
262+
]
263+
}
264+
});
265+
266+
beforeEach(async () => {
267+
await server.start();
268+
await server.forGet('/').thenReply(200, "Mock response");
269+
});
270+
271+
afterEach(async () => {
272+
await server.stop()
273+
});
274+
275+
it("handles matching HTTPS requests", async () => {
276+
const response: http.IncomingMessage = await new Promise((resolve) =>
277+
https.get({
278+
host: 'localhost',
279+
port: server.port,
280+
servername: 'wikipedia.org',
281+
headers: { 'Host': 'wikipedia.org' }
282+
}).on('response', resolve)
283+
);
284+
285+
expect(response.statusCode).to.equal(200);
286+
const body = (await streamToBuffer(response)).toString();
287+
expect(body).to.equal("Mock response");
288+
});
289+
290+
it("skips the server for non-matching HTTPS requests", async function () {
291+
this.retries(3); // Example.com can be unreliable
292+
293+
const response: http.IncomingMessage = await new Promise((resolve, reject) =>
294+
https.get({
295+
host: 'localhost',
296+
port: server.port,
297+
servername: 'example.com',
298+
headers: { 'Host': 'example.com' }
299+
}).on('response', resolve).on('error', reject)
300+
);
301+
302+
expect(response.statusCode).to.equal(200);
303+
const body = (await streamToBuffer(response)).toString();
304+
expect(body).to.include(
305+
"This domain is for use in illustrative examples in documents."
306+
);
307+
});
308+
});
309+
255310
describe("with some hostnames included", () => {
256311
let server = getLocal({
257312
https: {
@@ -306,5 +361,60 @@ describe("When configured for HTTPS", () => {
306361
);
307362
});
308363
});
364+
365+
describe("with wildcards hostnames included", () => {
366+
let server = getLocal({
367+
https: {
368+
keyPath: './test/fixtures/test-ca.key',
369+
certPath: './test/fixtures/test-ca.pem',
370+
tlsInterceptOnly: [
371+
{ hostname: '*.org' }
372+
]
373+
}
374+
});
375+
376+
beforeEach(async () => {
377+
await server.start();
378+
await server.forGet('/').thenReply(200, "Mock response");
379+
});
380+
381+
afterEach(async () => {
382+
await server.stop()
383+
});
384+
385+
it("handles matching HTTPS requests", async () => {
386+
const response: http.IncomingMessage = await new Promise((resolve) =>
387+
https.get({
388+
host: 'localhost',
389+
port: server.port,
390+
servername: 'wikipedia.org',
391+
headers: { 'Host': 'wikipedia.org' }
392+
}).on('response', resolve)
393+
);
394+
395+
expect(response.statusCode).to.equal(200);
396+
const body = (await streamToBuffer(response)).toString();
397+
expect(body).to.equal("Mock response");
398+
});
399+
400+
it("skips the server for non-matching HTTPS requests", async function () {
401+
this.retries(3); // Example.com can be unreliable
402+
403+
const response: http.IncomingMessage = await new Promise((resolve, reject) =>
404+
https.get({
405+
host: 'localhost',
406+
port: server.port,
407+
servername: 'example.com',
408+
headers: { 'Host': 'example.com' }
409+
}).on('response', resolve).on('error', reject)
410+
);
411+
412+
expect(response.statusCode).to.equal(200);
413+
const body = (await streamToBuffer(response)).toString();
414+
expect(body).to.include(
415+
"This domain is for use in illustrative examples in documents."
416+
);
417+
});
418+
});
309419
});
310420
});

test/server-utils.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { URLPattern } from "urlpattern-polyfill";
2+
import { expect } from "./test-utils";
3+
import { shouldPassThrough } from "../src/util/server-utils";
4+
5+
describe("shouldPassThrough", () => {
6+
it("should return false when passThroughHostnames is empty and interceptOnlyHostnames is undefined", async () => {
7+
const should = shouldPassThrough("example.org", [], undefined);
8+
expect(should).to.be.false;
9+
});
10+
11+
it("should return true when both lists empty", async () => {
12+
const should = shouldPassThrough("example.org", [], []);
13+
expect(should).to.be.true;
14+
});
15+
16+
it("should return false when hostname is falsy", () => {
17+
const should = shouldPassThrough("", [], []);
18+
expect(should).to.be.false;
19+
});
20+
21+
describe("passThroughHostnames", () => {
22+
it("should return true when hostname is in passThroughHostnames", () => {
23+
const should = shouldPassThrough(
24+
"example.org",
25+
[new URLPattern("https://example.org")],
26+
undefined
27+
);
28+
expect(should).to.be.true;
29+
});
30+
31+
it("should return false when hostname is not in passThroughHostnames", () => {
32+
const should = shouldPassThrough(
33+
"example.org",
34+
[new URLPattern("https://example.com")],
35+
undefined
36+
);
37+
expect(should).to.be.false;
38+
});
39+
40+
it("should return true when hostname match a wildcard", () => {
41+
const should = shouldPassThrough(
42+
"example.org",
43+
[new URLPattern("https://*.org")],
44+
undefined
45+
);
46+
expect(should).to.be.true;
47+
});
48+
});
49+
describe("interceptOnlyHostnames", () => {
50+
it("should return false when hostname is in interceptOnlyHostnames", () => {
51+
const should = shouldPassThrough(
52+
"example.org",
53+
[],
54+
[new URLPattern("https://example.org")]
55+
);
56+
expect(should).to.be.false;
57+
});
58+
59+
it("should return true when hostname is not in interceptOnlyHostnames", () => {
60+
const should = shouldPassThrough(
61+
"example.org",
62+
[],
63+
[new URLPattern("https://example.com")]
64+
);
65+
expect(should).to.be.true;
66+
});
67+
68+
it("should return false when hostname match a wildcard", () => {
69+
const should = shouldPassThrough(
70+
"example.org",
71+
[],
72+
[new URLPattern("https://*.org")]
73+
);
74+
expect(should).to.be.false;
75+
});
76+
});
77+
});

0 commit comments

Comments
 (0)