Skip to content

Commit 292d1b6

Browse files
authored
feat: https proxy handle self-signed certs (not added as root CA) (#7)
* fix(dev-deps): update http-proxy-agent and https-proxy-agent to v7, patch HttpsProxyAgent
1 parent 8863877 commit 292d1b6

3 files changed

Lines changed: 147 additions & 56 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
"eslint-plugin-promise": "^6.1.1",
2828
"eslint-plugin-standard": "^4.0.0",
2929
"fetch-mock": "^9.0.0",
30-
"http-proxy-agent": "^4.0.1",
31-
"https-proxy-agent": "2.2.4",
30+
"http-proxy-agent": "^7",
31+
"https-proxy-agent": "^7",
3232
"jest": "^29",
3333
"jest-fetch-mock": "^3.0.1",
3434
"jest-html-reporter": "^3.4.1",

src/proxy.js

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const os = require('os')
2222
* @typedef {object} HttpOptions the http proxy options
2323
* @property {number} port the port to use
2424
* @property {boolean} useBasicAuth use basic authorization
25+
* @property {boolean} [selfSigned=false] use self-signed certs
2526
* @property {boolean} [username=admin] the username for basic authorization
2627
* @property {boolean} [password=secret] the password for basic authorization
2728
*/
@@ -48,21 +49,27 @@ async function createHttpProxy (httpOptions = {}) {
4849
}
4950

5051
/**
51-
* Generate certs for SSL, and add it to the root CAs temporarily.
52-
* This prevents any self-signed cert errors for tests when using the https proxy.
52+
* Generate certs for SSL, and add it to the root CAs.
53+
* If added to the root CAs, this prevents any self-signed cert errors for tests when using the https proxy.
5354
*
5455
* @private
56+
* @param {object} options the options
57+
* @param {boolean} options.addToRootCAs defaults to true to add the cert to the root CAs temporarily
5558
* @returns {object} the https object containing the cert and key
5659
*/
57-
async function generateCertAndAddToRootCAs () {
60+
async function generateCert (options = {}) {
61+
const { addToRootCAs = true } = options
62+
5863
const https = await mockttp.generateCACertificate()
5964

6065
const tmpFolder = await mkdtemp(path.join(os.tmpdir(), 'test-proxy-'))
6166
const certPath = path.join(tmpFolder, 'server.crt')
6267
await writeFile(certPath, https.cert)
6368

64-
const syswidecas = require('syswide-cas')
65-
syswidecas.addCAs(certPath)
69+
if (addToRootCAs) {
70+
const syswidecas = require('syswide-cas')
71+
syswidecas.addCAs(certPath)
72+
}
6673

6774
return https
6875
}
@@ -83,15 +90,15 @@ function setupServerRules (server, httpOptions) {
8390
const passThroughOptions = { ignoreHostHttpsErrors: ['localhost', '127.0.0.1'] }
8491

8592
if (!useBasicAuth) {
86-
server.anyRequest().thenPassThrough(passThroughOptions)
93+
server.forAnyRequest().thenPassThrough(passThroughOptions)
8794
} else {
8895
const authorization = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64')
8996
// this rule makes the request pass through (authorization correct)
90-
server.anyRequest()
97+
server.forAnyRequest()
9198
.withHeaders({ 'Proxy-Authorization': authorization })
9299
.thenPassThrough(passThroughOptions)
93100
// this rule makes any other request fail
94-
server.anyRequest().thenReply(403)
101+
server.forAnyRequest().thenReply(403)
95102
}
96103

97104
return server
@@ -110,8 +117,9 @@ function setupServerRules (server, httpOptions) {
110117
* @returns {Promise<mockttp.Mockttp>} the proxy server instance
111118
*/
112119
async function createHttpsProxy (httpOptions = {}) {
113-
const { port = 8081 } = httpOptions
114-
const https = await generateCertAndAddToRootCAs()
120+
const { port = 8081, selfSigned = false } = httpOptions
121+
122+
const https = await generateCert({ addToRootCAs: !selfSigned })
115123
const server = mockttp.getLocal({ https })
116124

117125
setupServerRules(server, httpOptions)
@@ -123,6 +131,7 @@ async function createHttpsProxy (httpOptions = {}) {
123131
}
124132

125133
module.exports = {
134+
generateCert,
126135
createHttpProxy,
127136
createHttpsProxy
128137
}

test/proxy.test.js

Lines changed: 126 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,33 @@ governing permissions and limitations under the License.
1010
*/
1111

1212
const queryString = require('query-string')
13-
const { createHttpsProxy, createHttpProxy } = require('../src/proxy')
13+
const { createHttpsProxy, createHttpProxy, generateCert } = require('../src/proxy')
1414
const { createApiServer, HOSTNAME } = require('../src/api-server')
1515
const fetch = require('node-fetch')
16-
const HttpsProxyAgent = require('https-proxy-agent')
17-
const HttpProxyAgent = require('http-proxy-agent')
16+
const { HttpsProxyAgent } = require('https-proxy-agent')
17+
const { HttpProxyAgent } = require('http-proxy-agent')
1818
const url = require('url')
19+
const syswidecas = require('syswide-cas')
20+
21+
jest.mock('syswide-cas')
22+
23+
/**
24+
* HttpsProxyAgent needs a patch for TLS connections.
25+
* It doesn't pass in the original options during a SSL connect.
26+
*
27+
* See https://github.com/TooTallNate/proxy-agents/issues/89
28+
* An alternative is to use https://github.com/delvedor/hpagent
29+
*/
30+
class PatchedHttpsProxyAgent extends HttpsProxyAgent {
31+
constructor (proxyUrl, opts) {
32+
super(proxyUrl, opts)
33+
this.savedOpts = opts
34+
}
35+
36+
async connect (req, opts) {
37+
return super.connect(req, { ...this.savedOpts, ...opts })
38+
}
39+
}
1940

2041
/**
2142
* Converts a URL to a suitable object for http request options.
@@ -154,10 +175,11 @@ describe('https proxy', () => {
154175
const protocol = 'https'
155176
let proxyServer, apiServer
156177
const portNotInUse = 3009
178+
const selfSigned = true
157179

158-
describe('no auth', () => {
180+
describe('no auth (self-signed)', () => {
159181
beforeAll(async () => {
160-
proxyServer = await createHttpsProxy()
182+
proxyServer = await createHttpsProxy({ selfSigned })
161183
apiServer = await createApiServer({ port: 3001, useSsl: true })
162184
})
163185

@@ -174,17 +196,25 @@ describe('https proxy', () => {
174196

175197
const proxyUrl = proxyServer.url
176198
const proxyOpts = urlToHttpOptions(proxyUrl)
177-
// the passing on of this property to the underlying implementation only works on https-proxy-agent@2.2.4
178-
// this is only used for unit-tests and passed in the constructor
179-
proxyOpts.rejectUnauthorized = false
180-
proxyOpts.ALPNProtocols = ['http/1.1']
181199

182-
const response = await fetch(testUrl, {
183-
agent: new HttpsProxyAgent(proxyOpts)
184-
})
200+
// IGNORE self-signed certs
201+
{
202+
proxyOpts.rejectUnauthorized = false
203+
const response = await fetch(testUrl, {
204+
agent: new PatchedHttpsProxyAgent(proxyUrl, proxyOpts)
205+
})
185206

186-
const json = await response.json()
187-
expect(json).toStrictEqual(queryObject)
207+
const json = await response.json()
208+
expect(json).toStrictEqual(queryObject)
209+
}
210+
// DO NOT IGNORE self-signed certs
211+
{
212+
proxyOpts.rejectUnauthorized = true
213+
const proxyFetch = fetch(testUrl, {
214+
agent: new PatchedHttpsProxyAgent(proxyUrl, proxyOpts)
215+
})
216+
await expect(proxyFetch).rejects.toThrow('self-signed certificate in certificate chain')
217+
}
188218
})
189219

190220
test('failure', async () => {
@@ -195,20 +225,31 @@ describe('https proxy', () => {
195225
const proxyOpts = urlToHttpOptions(proxyUrl)
196226
// the passing on of this property to the underlying implementation only works on https-proxy-agent@2.2.4
197227
// this is only used for unit-tests and passed in the constructor
198-
proxyOpts.rejectUnauthorized = false
199228
proxyOpts.ALPNProtocols = ['http/1.1']
200229

201-
const response = await fetch(testUrl, {
202-
agent: new HttpsProxyAgent(proxyOpts)
203-
})
204-
expect(response.ok).toEqual(false)
205-
expect(response.status).toEqual(502)
230+
// IGNORE self-signed certs
231+
{
232+
proxyOpts.rejectUnauthorized = false
233+
const response = await fetch(testUrl, {
234+
agent: new PatchedHttpsProxyAgent(proxyUrl, proxyOpts)
235+
})
236+
expect(response.ok).toEqual(false)
237+
expect(response.status).toEqual(502)
238+
}
239+
// DO NOT IGNORE self-signed certs
240+
{
241+
proxyOpts.rejectUnauthorized = true
242+
const proxyFetch = fetch(testUrl, {
243+
agent: new PatchedHttpsProxyAgent(proxyUrl, proxyOpts)
244+
})
245+
await expect(proxyFetch).rejects.toThrow('self-signed certificate in certificate chain')
246+
}
206247
})
207248
})
208249

209250
describe('basic auth', () => {
210251
beforeAll(async () => {
211-
proxyServer = await createHttpsProxy({ useBasicAuth: true })
252+
proxyServer = await createHttpsProxy({ useBasicAuth: true, selfSigned })
212253
apiServer = await createApiServer({ port: 3001, useSsl: true })
213254
})
214255

@@ -229,19 +270,27 @@ describe('https proxy', () => {
229270
const proxyUrl = proxyServer.url
230271
const proxyOpts = urlToHttpOptions(proxyUrl)
231272
proxyOpts.auth = `${username}:${password}`
232-
// the passing on of this property to the underlying implementation only works on https-proxy-agent@2.2.4
233-
// this is only used for unit-tests and passed in the constructor
234-
proxyOpts.rejectUnauthorized = false
235-
proxyOpts.ALPNProtocols = ['http/1.1']
236-
237273
const testUrl = `${protocol}://${HOSTNAME}:${apiServerPort}/mirror?${queryString.stringify(queryObject)}`
238-
const response = await fetch(testUrl, {
239-
agent: new HttpsProxyAgent(proxyOpts),
240-
headers
241-
})
242-
expect(response.ok).toEqual(true)
243-
const json = await response.json()
244-
expect(json).toStrictEqual(queryObject)
274+
275+
// IGNORE self-signed certs
276+
{
277+
proxyOpts.rejectUnauthorized = false
278+
const response = await fetch(testUrl, {
279+
agent: new PatchedHttpsProxyAgent(proxyUrl, proxyOpts),
280+
headers
281+
})
282+
expect(response.ok).toEqual(true)
283+
const json = await response.json()
284+
expect(json).toStrictEqual(queryObject)
285+
}
286+
// DO NOT IGNORE self-signed certs
287+
{
288+
proxyOpts.rejectUnauthorized = true
289+
const proxyFetch = fetch(testUrl, {
290+
agent: new PatchedHttpsProxyAgent(proxyUrl, proxyOpts)
291+
})
292+
await expect(proxyFetch).rejects.toThrow('self-signed certificate in certificate chain')
293+
}
245294
})
246295

247296
test('failure', async () => {
@@ -256,18 +305,51 @@ describe('https proxy', () => {
256305
const proxyUrl = proxyServer.url
257306
const proxyOpts = urlToHttpOptions(proxyUrl)
258307
proxyOpts.auth = `${username}:${password}`
259-
// the passing on of this property to the underlying implementation only works on https-proxy-agent@2.2.4
260-
// this is only used for unit-tests and passed in the constructor
261-
proxyOpts.rejectUnauthorized = false
262-
proxyOpts.ALPNProtocols = ['http/1.1']
263-
264308
const testUrl = `${protocol}://${HOSTNAME}:${apiServerPort}/mirror?${queryString.stringify(queryObject)}`
265-
const response = await fetch(testUrl, {
266-
agent: new HttpsProxyAgent(proxyOpts),
267-
headers
268-
})
269-
expect(response.ok).toEqual(false)
270-
expect(response.status).toEqual(403)
309+
310+
// IGNORE self-signed certs
311+
{
312+
proxyOpts.rejectUnauthorized = false
313+
const response = await fetch(testUrl, {
314+
agent: new PatchedHttpsProxyAgent(proxyUrl, proxyOpts),
315+
headers
316+
})
317+
expect(response.ok).toEqual(false)
318+
expect(response.status).toEqual(403)
319+
}
320+
// DO NOT IGNORE self-signed certs
321+
{
322+
proxyOpts.rejectUnauthorized = true
323+
const proxyFetch = fetch(testUrl, {
324+
agent: new PatchedHttpsProxyAgent(proxyUrl, proxyOpts)
325+
})
326+
await expect(proxyFetch).rejects.toThrow('self-signed certificate in certificate chain')
327+
}
271328
})
272329
})
330+
331+
test('createHttpsProxy (default options)', async () => {
332+
syswidecas.addCAs.mockRestore()
333+
334+
proxyServer = await createHttpsProxy()
335+
await proxyServer.stop()
336+
337+
expect(syswidecas.addCAs).toHaveBeenCalled()
338+
})
339+
})
340+
341+
describe('generateCert', () => {
342+
beforeEach(() => {
343+
syswidecas.addCAs.mockRestore()
344+
})
345+
346+
test('default (add root CAs)', async () => {
347+
await generateCert()
348+
expect(syswidecas.addCAs).toHaveBeenCalled()
349+
})
350+
351+
test('do not add root CAs', async () => {
352+
await generateCert({ addToRootCAs: false })
353+
expect(syswidecas.addCAs).not.toHaveBeenCalled()
354+
})
273355
})

0 commit comments

Comments
 (0)