From 8717dadb50b78e4604120050a3477b60e6ac72ea Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Wed, 16 Jan 2019 14:34:32 +0100 Subject: [PATCH] Allow disabling certificate checks for specific hosts when passing through requests --- src/rules/handlers.ts | 18 ++++++++++++-- src/rules/mock-rule-builder.ts | 27 ++++++++++++++------ src/util/tls.ts | 11 ++++++--- test/integration/proxy.spec.ts | 45 ++++++++++++++++++++++++++-------- 4 files changed, 77 insertions(+), 24 deletions(-) diff --git a/src/rules/handlers.ts b/src/rules/handlers.ts index 4f85024b4..427d4ce40 100644 --- a/src/rules/handlers.ts +++ b/src/rules/handlers.ts @@ -381,11 +381,22 @@ export class StreamHandlerData extends Serializable { } } +export interface PassThroughHandlerOptions { + forwardToLocation?: string; + ignoreHostCertificateErrors?: string[]; +} + export class PassThroughHandlerData extends Serializable { readonly type: 'passthrough' = 'passthrough'; - constructor(private forwardToLocation?: string) { + private forwardToLocation?: string; + private ignoreHostCertificateErrors: string[] = []; + + constructor(options: PassThroughHandlerOptions = {}) { super(); + + this.forwardToLocation = options.forwardToLocation; + this.ignoreHostCertificateErrors = options.ignoreHostCertificateErrors || []; } buildHandler() { @@ -419,6 +430,8 @@ export class PassThroughHandlerData extends Serializable { protocol = clientReq.protocol + ':'; } + const checkServerCertificate = !_.includes(this.ignoreHostCertificateErrors, hostname); + let makeRequest = protocol === 'https:' ? https.request : http.request; let outgoingPort: null | number = null; @@ -429,7 +442,8 @@ export class PassThroughHandlerData extends Serializable { hostname, port, path, - headers + headers, + rejectUnauthorized: checkServerCertificate }, (serverRes) => { Object.keys(serverRes.headers).forEach((header) => { try { diff --git a/src/rules/mock-rule-builder.ts b/src/rules/mock-rule-builder.ts index b7bb66db7..a23a6974e 100644 --- a/src/rules/mock-rule-builder.ts +++ b/src/rules/mock-rule-builder.ts @@ -4,7 +4,7 @@ import url = require("url"); import { OutgoingHttpHeaders } from "http"; -import { merge } from "lodash"; +import { merge, defaults } from "lodash"; import { Readable } from "stream"; import { stripIndent } from "common-tags"; @@ -43,7 +43,8 @@ import { CallbackHandlerResult, StreamHandlerData, CloseConnectionHandlerData, - TimeoutHandlerData + TimeoutHandlerData, + PassThroughHandlerOptions } from "./handlers"; /** @@ -303,6 +304,11 @@ export default class MockRuleBuilder { * for proxied requests only, direct requests will be rejected with * an error. * + * This method takes options to configure how the request is passed + * through. The only option currently supported is ignoreHostCertificateErrors, + * a list of hostnames for which server certificate errors should + * be ignored (none, by default). + * * Calling this method registers the rule with the server, so it * starts to handle requests. * @@ -311,11 +317,11 @@ export default class MockRuleBuilder { * before sending requests to be matched. The mocked endpoint * can be used to assert on the requests matched by this rule. */ - thenPassThrough(): Promise { + thenPassThrough(options?: PassThroughHandlerOptions): Promise { const rule: MockRuleData = { matchers: this.matchers, completionChecker: this.isComplete, - handler: new PassThroughHandlerData() + handler: new PassThroughHandlerData(options) }; return this.addRule(rule); @@ -326,6 +332,11 @@ export default class MockRuleBuilder { * specified must not include a path. Otherwise, an error is thrown. * The path portion of the original request url is used instead. * + * This method also takes options to configure how the request is passed + * through. The only option currently supported is ignoreHostCertificateErrors, + * a list of hostnames for which server certificate errors should + * be ignored (none, by default). + * * Calling this method registers the rule with the server, so it * starts to handle requests. * @@ -334,12 +345,12 @@ export default class MockRuleBuilder { * before sending requests to be matched. The mocked endpoint * can be used to assert on the requests matched by this rule. */ - async thenForwardTo(forwardToUrl: string): Promise { - const { protocol, hostname, port, path } = url.parse(forwardToUrl); + async thenForwardTo(forwardToLocation: string, options?: PassThroughHandlerOptions): Promise { + const { protocol, hostname, port, path } = url.parse(forwardToLocation); if (path && path.trim() !== "/") { const suggestion = url.format({ protocol, hostname, port }); throw new Error(stripIndent` - URLs passed to thenForwardTo cannot include a path, but "${forwardToUrl}" does. ${'' + URLs passed to thenForwardTo cannot include a path, but "${forwardToLocation}" does. ${'' }Did you mean ${suggestion}? `); } @@ -347,7 +358,7 @@ export default class MockRuleBuilder { const rule: MockRuleData = { matchers: this.matchers, completionChecker: this.isComplete, - handler: new PassThroughHandlerData(forwardToUrl) + handler: new PassThroughHandlerData(defaults({ forwardToLocation }, options)) }; return this.addRule(rule); diff --git a/src/util/tls.ts b/src/util/tls.ts index a402aee38..2321b3102 100644 --- a/src/util/tls.ts +++ b/src/util/tls.ts @@ -41,10 +41,13 @@ export type GeneratedCertificate = { * These can be saved to disk, and their paths passed * as HTTPS options to a Mockttp server. */ -export function generateCACertificate(options: { commonName: string } = { - commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY', -}) { - const keyPair = pki.rsa.generateKeyPair(2048); +export function generateCACertificate(options: { commonName?: string, bytes?: number } = {}) { + options = _.defaults({}, options, { + commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY', + bytes: 2048 + }); + + const keyPair = pki.rsa.generateKeyPair(options.bytes); const cert = pki.createCertificate(); cert.publicKey = keyPair.publicKey; cert.serialNumber = uuid().replace(/-/g, ''); diff --git a/test/integration/proxy.spec.ts b/test/integration/proxy.spec.ts index ac2da0824..e31b75954 100644 --- a/test/integration/proxy.spec.ts +++ b/test/integration/proxy.spec.ts @@ -2,7 +2,7 @@ import _ = require("lodash"); import { getLocal, Mockttp } from "../.."; import request = require("request-promise-native"); import { expect, nodeOnly } from "../test-utils"; -import { getCA } from "../../src/util/tls"; +import { getCA, generateCACertificate } from "../../src/util/tls"; const INITIAL_ENV = _.cloneDeep(process.env); @@ -143,15 +143,10 @@ nodeOnly(() => { describe("given an untrusted upstream certificate", () => { let badServer: Mockttp; + const untrustedCACert = generateCACertificate({ bytes: 1024 }); beforeEach(async () => { - const ca = await getCA({ - keyPath: './test/fixtures/test-ca.key', - certPath: './test/fixtures/test-ca.pem' - }); - const certificate = ca.generateCertificate('wrong-domain'); - - badServer = getLocal({ https: certificate }); + badServer = getLocal({ https: untrustedCACert }); await badServer.start(); }); @@ -160,9 +155,39 @@ nodeOnly(() => { it("should refuse to pass through requests", async () => { await badServer.anyRequest().thenReply(200); - await server.get(badServer.urlFor('/')).thenPassThrough(); + await server.anyRequest().thenPassThrough(); + + let response = await request.get(badServer.url, { + resolveWithFullResponse: true, + simple: false + }); + + expect(response.statusCode).to.equal(502); + }); + + it("should allow passing through requests if the host is specifically listed", async () => { + await badServer.anyRequest().thenReply(200); + + await server.anyRequest().thenPassThrough({ + ignoreHostCertificateErrors: ['localhost'] + }); + + let response = await request.get(badServer.url, { + resolveWithFullResponse: true, + simple: false + }); + + expect(response.statusCode).to.equal(200); + }); + + it("should allow passing through requests if a non-matching host is specifically listed", async () => { + await badServer.anyRequest().thenReply(200); + + await server.get(badServer.urlFor('/')).thenPassThrough({ + ignoreHostCertificateErrors: ['differenthost'] + }); - let response = await request.get(badServer.urlFor('/'), { + let response = await request.get(badServer.url, { resolveWithFullResponse: true, simple: false });