Skip to content

Commit

Permalink
Merge pull request #171 from lipsumar/add-wildcard-support-for-passth…
Browse files Browse the repository at this point in the history
…rough

Add wildcard support for passthrough
  • Loading branch information
pimterry authored Jun 3, 2024
2 parents 235a512 + 6e64526 commit b916796
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 21 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
12 changes: 10 additions & 2 deletions src/mockttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand All @@ -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 }>;
Expand Down
25 changes: 6 additions & 19 deletions src/server/http-combo-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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
}
Expand All @@ -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);
}
18 changes: 18 additions & 0 deletions src/util/server-utils.ts
Original file line number Diff line number Diff line change
@@ -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}`)
);
}
110 changes: 110 additions & 0 deletions test/integration/https.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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."
);
});
});
});
});
77 changes: 77 additions & 0 deletions test/server-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
});

0 comments on commit b916796

Please sign in to comment.