From 41f3519dccbfe67209b60f21f4889e673771c42f Mon Sep 17 00:00:00 2001 From: lohit-bruno Date: Wed, 4 Mar 2026 17:56:46 +0530 Subject: [PATCH] fix(proxy): fix proxy agent construction and CA cert handling Three fixes: 1. Proxy agents (HttpsProxyAgent, HttpProxyAgent, SocksProxyAgent) expect (proxyUri, options) constructor signature, but the agent cache was packing proxyUri into options as a single argument. Fixed the non-timeline code path in getOrCreateAgentInternal. 2. HTTP requests through an HTTPS proxy need TLS options (ca certs) to validate the proxy's certificate. All getOrCreateHttpAgent call sites now pass TLS options when the proxy protocol is HTTPS. 3. Setting the `ca` option on any Node.js TLS connection replaces the default OpenSSL trust store entirely. CAs only in the OpenSSL default trust store (e.g. /etc/ssl/cert.pem) but not in tls.rootCertificates were lost. Fixed by converting `ca` to a secureContext via addCACert(), which appends custom CAs on top of the OpenSSL defaults instead of replacing them. Also simplified PatchedHttpsProxyAgent to selectively forward only the relevant TLS options (cert, key, pfx, passphrase, rejectUnauthorized, secureContext) to the target TLS upgrade instead of blindly merging all constructor options. --- .../src/runner/run-single-request.js | 15 ++-- packages/bruno-cli/src/utils/proxy-util.js | 27 +++++-- .../bruno-electron/src/utils/proxy-util.js | 42 ++++++++--- .../bruno-requests/src/utils/agent-cache.ts | 71 +++++++++++++++++-- .../src/utils/http-https-agents.ts | 47 +++++++++--- .../src/utils/timeline-agent.ts | 5 +- 6 files changed, 174 insertions(+), 33 deletions(-) diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 195696401..9b15f308a 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -459,18 +459,23 @@ const runSingleRequest = async function ( } else { proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; } + // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options + // (e.g., ca certs) even for plain HTTP requests + const isHttpsProxy = proxyProtocol === 'https'; + const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions; + // Only set the agent needed for the request protocol if (socksEnabled) { if (isHttpsRequest) { request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname }); } else { - request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpAgentOptions, proxyUri, disableCache, hostname }); + request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname }); } } else { if (isHttpsRequest) { request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname }); } else { - request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpAgentOptions, proxyUri, disableCache, hostname }); + request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname }); } } } @@ -481,8 +486,10 @@ const runSingleRequest = async function ( if (shouldUseSystemProxy) { try { if (http_proxy?.length && !isHttpsRequest) { - new URL(http_proxy); - request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpAgentOptions, proxyUri: http_proxy, disableCache, hostname }); + const parsedHttpProxy = new URL(http_proxy); + const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:'; + const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions; + request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname }); } } catch (error) { throw new Error('Invalid system http_proxy'); diff --git a/packages/bruno-cli/src/utils/proxy-util.js b/packages/bruno-cli/src/utils/proxy-util.js index 729e03356..70d1b4057 100644 --- a/packages/bruno-cli/src/utils/proxy-util.js +++ b/packages/bruno-cli/src/utils/proxy-util.js @@ -63,9 +63,17 @@ const shouldUseProxy = (url, proxyBypass) => { }; /** - * Patched version of HttpsProxyAgent to get around a bug that ignores - * options like ca and rejectUnauthorized when upgrading the socket to TLS: - * https://github.com/TooTallNate/proxy-agents/issues/194 + * Options that should be forwarded from the constructor to the target TLS upgrade. + */ +const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext']; + +/** + * Patched version of HttpsProxyAgent that correctly handles TLS options for + * both the proxy connection and the target server connection. + * + * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194) + * ignores constructor options when upgrading the tunneled socket to TLS for the + * target server. This patch forwards the relevant TLS options to the target upgrade. */ class PatchedHttpsProxyAgent extends HttpsProxyAgent { constructor(proxy, opts) { @@ -74,8 +82,17 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } async connect(req, opts) { - const combinedOpts = { ...this.constructorOpts, ...opts }; - return super.connect(req, combinedOpts); + const targetOpts = { ...opts }; + + if (this.constructorOpts) { + for (const key of TARGET_TLS_OPTIONS) { + if (key in this.constructorOpts) { + targetOpts[key] = this.constructorOpts[key]; + } + } + } + + return super.connect(req, targetOpts); } } diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js index 71b2084b4..843994556 100644 --- a/packages/bruno-electron/src/utils/proxy-util.js +++ b/packages/bruno-electron/src/utils/proxy-util.js @@ -70,9 +70,17 @@ const shouldUseProxy = (url, proxyBypass) => { }; /** - * Patched version of HttpsProxyAgent to get around a bug that ignores options - * such as ca and rejectUnauthorized when upgrading the proxied socket to TLS: - * https://github.com/TooTallNate/proxy-agents/issues/194 + * Options that should be forwarded from the constructor to the target TLS upgrade. + */ +const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext']; + +/** + * Patched version of HttpsProxyAgent that correctly handles TLS options for + * both the proxy connection and the target server connection. + * + * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194) + * ignores constructor options when upgrading the tunneled socket to TLS for the + * target server. This patch forwards the relevant TLS options to the target upgrade. */ class PatchedHttpsProxyAgent extends HttpsProxyAgent { constructor(proxy, opts) { @@ -81,8 +89,17 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } async connect(req, opts) { - const combinedOpts = { ...this.constructorOpts, ...opts }; - return super.connect(req, combinedOpts); + const targetOpts = { ...opts }; + + if (this.constructorOpts) { + for (const key of TARGET_TLS_OPTIONS) { + if (key in this.constructorOpts) { + targetOpts[key] = this.constructorOpts[key]; + } + } + } + + return super.connect(req, targetOpts); } } @@ -140,18 +157,23 @@ function setupProxyAgents({ }); } + // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options + // (e.g., ca certs) even for plain HTTP requests + const isHttpsProxy = proxyProtocol === 'https'; + const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true }; + // Only set the agent needed for the request protocol if (socksEnabled) { if (isHttpsRequest) { requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname }); } else { - requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname }); + requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname }); } } else { if (isHttpsRequest) { requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname }); } else { - requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname }); + requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname }); } } } @@ -161,7 +183,9 @@ function setupProxyAgents({ if (shouldUseSystemProxy) { try { if (http_proxy?.length && !isHttpsRequest) { - new URL(http_proxy); + const parsedHttpProxy = new URL(http_proxy); + const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:'; + const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true }; if (timeline) { timeline.push({ timestamp: new Date(), @@ -169,7 +193,7 @@ function setupProxyAgents({ message: `Using system proxy: ${http_proxy}` }); } - requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri: http_proxy, timeline, disableCache, hostname }); + requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, timeline, disableCache, hostname }); } } catch (error) { throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`); diff --git a/packages/bruno-requests/src/utils/agent-cache.ts b/packages/bruno-requests/src/utils/agent-cache.ts index cad517d71..0a154d6b5 100644 --- a/packages/bruno-requests/src/utils/agent-cache.ts +++ b/packages/bruno-requests/src/utils/agent-cache.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import tls from 'node:tls'; import type { Agent as HttpAgent } from 'node:http'; import type { Agent as HttpsAgent } from 'node:https'; import { createTimelineAgentClass, createTimelineHttpAgentClass, type TimelineEntry, type AgentOptions, type HttpAgentOptions, type AgentClass, type HttpAgentClass } from './timeline-agent'; @@ -56,6 +57,56 @@ function hashValue(value: string | Buffer | undefined): string | null { return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16); } +/** + * Cache for secure contexts created from CA options. + * Keyed by the hash of the CA value to avoid creating duplicate contexts. + */ +const secureContextCache = new Map(); + +/** + * Build a TLS secure context that adds custom CAs on top of the OpenSSL defaults. + * + * When Node.js receives an explicit `ca` option in tls.connect() or https.Agent, + * it replaces the default CA store entirely. This means CAs that are only in the + * OpenSSL default trust store (e.g. /etc/ssl/cert.pem) but not in + * tls.rootCertificates or tls.getCACertificates('system') are lost. + * + * This function creates a secureContext starting from the OpenSSL defaults + * and adds custom CAs on top via addCACert(), which appends rather than replaces. + */ +function buildSecureContext(ca: string | Buffer | (string | Buffer)[]): tls.SecureContext { + const caHash = hashCaValue(ca); + if (caHash && secureContextCache.has(caHash)) { + return secureContextCache.get(caHash)!; + } + + const ctx = tls.createSecureContext(); + const caList = Array.isArray(ca) ? ca : [ca]; + for (const cert of caList) { + if (cert) { + ctx.context.addCACert(cert); + } + } + + if (caHash) { + secureContextCache.set(caHash, ctx); + } + return ctx; +} + +/** + * Convert agent options to use a secureContext instead of raw `ca`. + * This ensures custom CAs are added on top of the OpenSSL defaults + * rather than replacing the default CA store. + */ +function applySecureContext(options: T): T { + if ('ca' in options && (options as AgentOptions).ca) { + const { ca, ...rest } = options as AgentOptions; + return { ...rest, secureContext: buildSecureContext(ca!) } as unknown as T; + } + return options; +} + /** * Hash a CA value which can be a single value or an array of certificates. * Node.js TLS options allow ca to be string | Buffer | (string | Buffer)[]. @@ -79,7 +130,7 @@ function getAgentCacheKey(agentClassId: number, options: AgentOptions, proxyUri: // Extract the TLS-relevant options for the cache key const keyData = { agentClassId, - hostname, + hostname: proxyUri?.length ? null : hostname, proxyUri, keepAlive: options.keepAlive, rejectUnauthorized: options.rejectUnauthorized, @@ -102,7 +153,7 @@ function getAgentCacheKey(agentClassId: number, options: AgentOptions, proxyUri: function getHttpAgentCacheKey(agentClassId: number, options: HttpAgentOptions, proxyUri: string | null = null, hostname: string | null = null): string { const keyData = { agentClassId, - hostname, + hostname: proxyUri?.length ? null : hostname, proxyUri, keepAlive: options.keepAlive }; @@ -189,8 +240,20 @@ function getOrCreateAgentInternal( } const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass; - const agentOptions = proxyUri ? { ...options, proxy: proxyUri } : options; - const agent = new AgentClass(agentOptions, timeline ?? undefined); + // Convert raw `ca` to a secureContext that adds CAs on top of OpenSSL defaults + const resolvedOptions = applySecureContext(options); + + let agent: HttpAgent | HttpsAgent; + if (timeline) { + // Timeline-wrapped classes handle proxy internally via options.proxy + const agentOptions = proxyUri ? { ...resolvedOptions, proxy: proxyUri } : resolvedOptions; + agent = new AgentClass(agentOptions, timeline); + } else if (proxyUri) { + // Proxy agent classes expect (proxyUri, options) constructor signature + agent = new BaseAgentClass(proxyUri, resolvedOptions); + } else { + agent = new BaseAgentClass(resolvedOptions); + } if (!disableCache) { // Evict oldest entry if cache is full (LRU eviction) diff --git a/packages/bruno-requests/src/utils/http-https-agents.ts b/packages/bruno-requests/src/utils/http-https-agents.ts index bb7bebe94..7bc848735 100644 --- a/packages/bruno-requests/src/utils/http-https-agents.ts +++ b/packages/bruno-requests/src/utils/http-https-agents.ts @@ -195,9 +195,21 @@ const shouldUseProxy = (url: string | undefined, proxyBypass: string | undefined }; /** - * Patched version of HttpsProxyAgent to get around a bug that ignores options - * such as ca and rejectUnauthorized when upgrading the proxied socket to TLS: - * https://github.com/TooTallNate/proxy-agents/issues/194 + * Options that should be forwarded from the constructor to the target TLS upgrade. + * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194) + * ignores constructor options when upgrading the tunneled socket to TLS for the + * target server. This list covers client certificates, verification, and secure context. + */ +const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'] as const; + +/** + * Patched version of HttpsProxyAgent that correctly handles TLS options for + * both the proxy connection and the target server connection. + * + * This patch forwards client certificate options, rejectUnauthorized, and + * secureContext to the target TLS upgrade. The agent-cache layer converts raw + * `ca` to a secureContext (via addCACert) before construction, so custom CAs + * are added on top of the OpenSSL defaults rather than replacing them. */ class PatchedHttpsProxyAgent extends HttpsProxyAgent { private constructorOpts: any; @@ -208,8 +220,18 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } async connect(req: any, opts: any) { - const combinedOpts = { ...this.constructorOpts, ...opts }; - return super.connect(req, combinedOpts); + const targetOpts = { ...opts }; + + // Forward TLS options to the target TLS upgrade + if (this.constructorOpts) { + for (const key of TARGET_TLS_OPTIONS) { + if (key in this.constructorOpts) { + targetOpts[key] = this.constructorOpts[key]; + } + } + } + + return super.connect(req, targetOpts); } } @@ -405,18 +427,23 @@ function createAgents({ proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; } + // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options + // (e.g., ca certs) even for plain HTTP requests + const isHttpsProxy = proxyProtocol === 'https'; + const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true }; + // Only set the agent needed for the request protocol if (socksEnabled) { if (isHttpsRequest) { httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent; } else { - httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname }); + httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }); } } else { if (isHttpsRequest) { httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent; } else { - httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname }); + httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }); } } } else { @@ -434,8 +461,10 @@ function createAgents({ if (shouldUseSystemProxy) { try { if (http_proxy?.length && !isHttpsRequest) { - new URL(http_proxy); - httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri: http_proxy, timeline: timeline || null, disableCache, hostname }); + const parsedHttpProxy = new URL(http_proxy); + const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:'; + const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true }; + httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions as any, proxyUri: http_proxy, timeline: timeline || null, disableCache, hostname }); } } catch (error) { throw new Error('Invalid system http_proxy'); diff --git a/packages/bruno-requests/src/utils/timeline-agent.ts b/packages/bruno-requests/src/utils/timeline-agent.ts index 5ec5c8432..e1d9e1a84 100644 --- a/packages/bruno-requests/src/utils/timeline-agent.ts +++ b/packages/bruno-requests/src/utils/timeline-agent.ts @@ -27,6 +27,7 @@ type AgentOptions = { ALPNProtocols?: string[]; caCertificatesCount?: CaCertificatesCount; proxy?: string; + secureContext?: any; }; type AgentClass = new (options: AgentOptions, timeline?: TimelineEntry[]) => https.Agent; @@ -86,7 +87,7 @@ function createTimelineAgentClass