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