mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 11:51:30 +00:00
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.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<string, tls.SecureContext>();
|
||||
|
||||
/**
|
||||
* 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<T extends AgentOptions | HttpAgentOptions>(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<TOptions extends HttpAgentOptions>(
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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<any> {
|
||||
private constructorOpts: any;
|
||||
@@ -208,8 +220,18 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -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<T extends ProxyAgentClass | typeof https.Agent
|
||||
super(proxyUri, tlsOptions);
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
|
||||
this.caProvided = !!tlsOptions.ca;
|
||||
this.caProvided = !!(tlsOptions.ca || tlsOptions.secureContext);
|
||||
|
||||
// Log TLS verification status and proxy details
|
||||
this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
|
||||
@@ -100,7 +101,7 @@ function createTimelineAgentClass<T extends ProxyAgentClass | typeof https.Agent
|
||||
super(tlsOptions);
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
this.alpnProtocols = optionsCopy.ALPNProtocols || ['h2', 'http/1.1'];
|
||||
this.caProvided = !!optionsCopy.ca;
|
||||
this.caProvided = !!(optionsCopy.ca || optionsCopy.secureContext);
|
||||
|
||||
// Log TLS verification status
|
||||
this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
|
||||
|
||||
Reference in New Issue
Block a user