fix: enable SSL session caching and HTTP agent reuse for faster consecutive requests (#6987)

* fix: enable SSL session caching for faster consecutive requests (#6929)

* fix: enable SSL session caching for faster consecutive requests

Previously, Bruno created a new HTTPS agent for every request, which meant
SSL/TLS sessions couldn't be reused. This caused the full TLS handshake
(~450ms) to run on every request, even to the same endpoint.

Changes:
- Add agent caching based on TLS configuration (certs, proxy, SSL options)
- Reuse cached agents for requests with matching configuration
- SSL sessions are now cached and reused, significantly reducing
  response time for consecutive requests to the same host

The fix maintains backward compatibility:
- Timeline logging moved to setup phase (before agent creation)
- Proxy and SSL validation behavior unchanged
- Added clearAgentCache() for testing and configuration changes

Fixes #5574

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address review feedback for SSL session caching

- Add passphrase to cache key to prevent incorrect agent reuse
- Add MAX_AGENT_CACHE_SIZE (100) with LRU-style eviction
- Use consistent node: prefix for crypto import

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: lohit <lohit@usebruno.com>

* feat(bruno-requests): add timeline agent for TLS event logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): add agent cache for SSL session reuse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(bruno-requests): add tests for agent cache

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): integrate agent cache into http-https-agents

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(bruno-electron): use shared agent cache from bruno-requests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-cli): use agent cache for SSL session reuse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): add HTTP agent timeline support

Add createTimelineHttpAgentClass for logging HTTP connection events
including proxy usage, DNS lookups, and connection establishment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(bruno-requests): extract shared agent caching logic

Add getOrCreateAgentInternal helper to reduce code duplication
between getOrCreateAgent and getOrCreateHttpAgent functions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): use HTTP agent cache for connection reuse

Export getOrCreateHttpAgent and use it in http-https-agents for
HTTP requests to enable connection pooling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(bruno-cli): improve HTTP agent handling and error logging

- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Add warning log for system proxy configuration errors
- Fix brace style consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(bruno-electron): improve HTTP agent handling

- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Fix brace style consistency
- Add missing newline at EOF

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(bruno-requests): address code review findings for agent caching

- Fix Buffer hashing bug: properly handle Buffer values in hashValue()
- Add CA array support: new hashCaValue() handles string[] | Buffer[]
- Fix timeline race condition: capture timeline reference in closure
  at createConnection start to isolate concurrent requests
- Fix SSL verify message: check socket.authorized for accurate status
- Fix HTTP/HTTPS agent logic: only set httpsAgent for HTTPS requests
- Add tests for concurrent requests timeline isolation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(bruno-requests): log when reusing cached agent

- HTTPS agents: "Reusing cached agent (SSL session reuse enabled)"
- HTTP agents: "Reusing cached agent (connection reuse enabled)"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(preferences): add cache.httpHttpsAgents.enabled preference

* feat(agent-cache): add disableCache option to getOrCreateAgent

* feat(proxy-util): respect httpHttpsAgents cache preference

* refactor(agent-cache): use named props for getOrCreateAgent and getOrCreateHttpAgent

* feat(ipc): add renderer:clear-http-https-agent-cache handler

* feat(redux): add cache.httpHttpsAgents preferences to initial state

* feat(ui): add Cache tab to Preferences

* feat(cli): add --disable-http-https-agents-cache flag

* refactor(cache): replace window.ipcRenderer calls with redux actions

Add getCacheStats, purgeCache, and clearHttpHttpsAgentCache thunks to
the app slice. Update the Cache preferences component to dispatch these
actions instead of calling window.ipcRenderer directly.

Also move handleSave and handleSaveRef above useFormik to fix declaration
order — onSubmit closes over handleSaveRef, so the ref must be initialized
before useFormik is called.

* fix: tests

* fix(cache): thread disableCache and hostname through all agent-creation paths

- Forward disableHttpHttpsAgentsCache through getHttpHttpsAgents → createAgents
  so OAuth2 token requests and bru.sendRequest honour the CLI flag
- Add hostname to agent cache keys (getAgentCacheKey, getHttpAgentCacheKey)
  for per-host TLS session reuse; extract hostname at every call site in
  run-single-request.js, proxy-util.js, and http-https-agents.ts
- Add extractHostname helper in http-https-agents.ts to safely parse hostnames
- Add test coverage for cert, key, pfx, passphrase, and hostname cache-key
  differentiation in agent-cache.spec.ts

* refactor(cache): rename getOrCreateAgent to getOrCreateHttpsAgent

* refactor: simplify UI labels, optimize agent timeline wrapping, silence proxy errors

* fix: tests

* 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.

* fix(tls): load client certs into secureContext to prevent silent drop

Add Cache tab to Preferences UI

* fix(proxy): align proxy auth check to use auth.disabled field consistently

* refactor(cache): rename CLI flag to --cache-ssl-session and disable caching by default

- Rename --disable-http-https-agents-cache to --cache-ssl-session (opt-in)
- Rename disableHttpHttpsAgentsCache to cacheSslSession across CLI and bruno-requests
- Default caching to disabled in both bruno-electron and bruno-cli
- Add cacheSslSession to buildCertsAndProxyConfig for bru.sendRequest
- Update Preferences UI labels to "Cache SSL Session"

* refactor(cache): rename httpHttpsAgents to sslSession across preferences and UI

* refactor(cache): remove unused getCacheStats and purgeCache IPC actions

---------

Co-authored-by: karthik <47263234+kxbnb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
lohit
2026-03-05 13:16:20 +00:00
committed by GitHub
parent 39f8ce2a2f
commit f5e437adaf
16 changed files with 1523 additions and 355 deletions

View File

@@ -0,0 +1,13 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
form.bruno-form {
label {
font-size: 0.8125rem;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,122 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import {
savePreferences,
clearHttpHttpsAgentCache
} from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
import get from 'lodash/get';
const cacheSchema = Yup.object().shape({
sslSession: Yup.object({
enabled: Yup.boolean()
})
});
const Cache = () => {
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
const handleSave = useCallback(
(newCachePreferences) => {
dispatch(
savePreferences({
...preferences,
cache: newCachePreferences
})
).catch(() => toast.error('Failed to update cache preferences'));
},
[dispatch, preferences]
);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const formik = useFormik({
initialValues: {
sslSession: {
enabled: get(preferences, 'cache.sslSession.enabled', false)
}
},
validationSchema: cacheSchema,
onSubmit: async (values) => {
try {
const newPreferences = await cacheSchema.validate(values, { abortEarly: true });
handleSave(newPreferences);
} catch (error) {
console.error('Cache preferences validation error:', error.message);
}
}
});
const debouncedSave = useCallback(
debounce((values) => {
cacheSchema
.validate(values, { abortEarly: true })
.then((validatedValues) => handleSaveRef.current(validatedValues))
.catch(() => {});
}, 500),
[]
);
useEffect(() => {
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
const handleAgentCachingChange = (e) => {
formik.handleChange(e);
// Immediately evict all cached agents when caching is disabled
if (!e.target.checked) {
dispatch(clearHttpHttpsAgentCache()).catch(() => {});
}
};
const handleResetCache = () => {
dispatch(clearHttpHttpsAgentCache())
.then(() => toast.success('ssl session cache cleared'))
.catch(() => toast.error('Failed to clear ssl session cache'));
};
return (
<StyledWrapper className="w-full">
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
<div className="flex items-center my-2">
<input
id="sslSession.enabled"
type="checkbox"
name="sslSession.enabled"
checked={formik.values.sslSession.enabled}
onChange={handleAgentCachingChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
Enable SSL session caching
</label>
</div>
<div className="text-xs mt-1 ml-6 opacity-70">
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every
request.
</div>
<div className="mt-6">
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
Clear
</button>
</div>
</form>
</StyledWrapper>
);
};
export default Cache;

View File

@@ -9,7 +9,8 @@ import {
IconUserCircle,
IconKeyboard,
IconZoomQuestion,
IconSquareLetterB
IconSquareLetterB,
IconDatabase
} from '@tabler/icons';
import Support from './Support';
@@ -21,6 +22,7 @@ import Keybindings from './Keybindings';
import Beta from './Beta';
import StyledWrapper from './StyledWrapper';
import Cache from './Cache/index';
const Preferences = () => {
const dispatch = useDispatch();
@@ -65,6 +67,10 @@ const Preferences = () => {
case 'support': {
return <Support />;
}
case 'cache': {
return <Cache />;
}
}
};
@@ -92,6 +98,10 @@ const Preferences = () => {
<IconKeyboard size={16} strokeWidth={1.5} />
Keybindings
</div>
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
<IconDatabase size={16} strokeWidth={1.5} />
Cache
</div>
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
<IconZoomQuestion size={16} strokeWidth={1.5} />
Support

View File

@@ -43,6 +43,11 @@ const initialState = {
autoSave: {
enabled: false,
interval: 1000
},
cache: {
sslSession: {
enabled: false
}
}
},
generateCode: {
@@ -301,4 +306,11 @@ export const refreshSystemProxy = () => (dispatch, getState) => {
});
};
export const clearHttpHttpsAgentCache = () => () => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:clear-http-https-agent-cache').then(resolve).catch(reject);
});
};
export default appSlice.reducer;

View File

@@ -225,6 +225,11 @@ const builder = async (yargs) => {
description: 'Disable all proxy settings (both collection-defined and system proxies)',
default: false
})
.option('cache-ssl-session', {
type: 'boolean',
description: 'Enable SSL session caching — reuses TLS sessions across requests for faster handshakes',
default: false
})
.option('delay', {
type: 'number',
description: 'Delay between each requests (in miliseconds)'
@@ -330,6 +335,7 @@ const handler = async function (argv) {
reporterSkipBody,
clientCertConfig,
noproxy,
cacheSslSession,
delay,
tags: includeTags,
excludeTags,
@@ -531,6 +537,9 @@ const handler = async function (argv) {
if (noproxy) {
options['noproxy'] = true;
}
if (cacheSslSession) {
options['cacheSslSession'] = true;
}
if (verbose) {
options['verbose'] = true;
}

View File

@@ -9,7 +9,8 @@ const { interpolateString, interpolateObject } = require('./interpolate-string')
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
const https = require('https');
const https = require('node:https');
const http = require('node:http');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance');
@@ -22,7 +23,7 @@ const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
const tokenStore = require('../store/tokenStore');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
@@ -203,7 +204,8 @@ const runSingleRequest = async function (
shouldVerifyTls: !get(options, 'insecure', false),
shouldUseCustomCaCertificate: !!options['cacert'],
customCaCertificateFilePath: options['cacert'],
shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
shouldKeepDefaultCaCertificates: !options['ignoreTruststore'],
cacheSslSession: get(options, 'cacheSslSession', false)
},
clientCertificates: rawClientCertificates ? interpolateObject(rawClientCertificates, sendRequestInterpolationOptions) : undefined,
collectionLevelProxy: transformProxyConfig(interpolateObject(rawProxyConfig, sendRequestInterpolationOptions)),
@@ -347,6 +349,7 @@ const runSingleRequest = async function (
const insecure = get(options, 'insecure', false);
const noproxy = get(options, 'noproxy', false);
const cachedSystemProxy = get(options, 'cachedSystemProxy', null);
const disableCache = !get(options, 'cacheSslSession', false);
const httpsAgentRequestFields = {};
if (insecure) {
@@ -426,6 +429,18 @@ const runSingleRequest = async function (
}
// else: collection proxy is disabled, proxyMode stays 'off'
// Prepare TLS options for agent caching
const tlsOptions = {
...httpsAgentRequestFields
};
// HTTP agent options — separate from tlsOptions to avoid leaking TLS fields
const httpAgentOptions = { keepAlive: true };
const parsedRequestUrl = new URL(request.url);
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
const hostname = parsedRequestUrl.hostname || null;
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
@@ -444,35 +459,37 @@ 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) {
request.httpsAgent = new SocksProxyAgent(
proxyUri,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
);
request.httpAgent = new SocksProxyAgent(proxyUri);
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
} else {
request.httpsAgent = new PatchedHttpsProxyAgent(
proxyUri,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
);
request.httpAgent = new HttpProxyAgent(proxyUri);
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
}
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
const parsedUrl = new URL(request.url);
const isHttpsRequest = parsedUrl.protocol === 'https:';
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
new URL(http_proxy);
request.httpAgent = new HttpProxyAgent(http_proxy);
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');
@@ -480,30 +497,21 @@ const runSingleRequest = async function (
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
request.httpsAgent = new PatchedHttpsProxyAgent(https_proxy,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined);
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
} catch (error) {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
} catch (error) {}
}
if (!request.httpAgent && !request.httpsAgent) {
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
}
} else if (Object.keys(httpsAgentRequestFields).length > 0) {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
// set cookies if enabled
@@ -610,12 +618,13 @@ const runSingleRequest = async function (
let token;
if (oauth2RequestUrl) {
const tlsOptions = {
const oauth2ConfigOptions = {
noproxy: options.noproxy,
shouldVerifyTls: !insecure,
shouldUseCustomCaCertificate: !!options['cacert'],
customCaCertificateFilePath: options['cacert'],
shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
shouldKeepDefaultCaCertificates: !options['ignoreTruststore'],
cacheSslSession: !disableCache
};
const clientCertificates = get(brunoConfig, 'clientCertificates');
@@ -627,7 +636,7 @@ const runSingleRequest = async function (
const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({
requestUrl: oauth2RequestUrl,
collectionPath,
options: tlsOptions,
options: oauth2ConfigOptions,
clientCertificates: interpolatedClientCertificates,
collectionLevelProxy: interpolatedProxyConfig,
systemProxyConfig

View File

@@ -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);
}
}

View File

@@ -194,7 +194,8 @@ const buildCertsAndProxyConfig = async ({
shouldVerifyTls: preferencesUtil.shouldVerifyTls(),
shouldUseCustomCaCertificate: preferencesUtil.shouldUseCustomCaCertificate(),
customCaCertificateFilePath: preferencesUtil.getCustomCaCertificateFilePath(),
shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates()
shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates(),
cacheSslSession: preferencesUtil.isSslSessionCachingEnabled()
};
// Get client certificates from bruno config and interpolate

View File

@@ -6,6 +6,7 @@ const { getCachedSystemProxy, fetchSystemProxy } = require('../store/system-prox
const { resolveDefaultLocation } = require('../utils/default-location');
const onboardUser = require('../app/onboarding');
const LastOpenedCollections = require('../store/last-opened-collections');
const { clearAgentCache } = require('@usebruno/requests');
const registerPreferencesIpc = (mainWindow) => {
const lastOpenedCollections = new LastOpenedCollections();
@@ -56,6 +57,14 @@ const registerPreferencesIpc = (mainWindow) => {
}
});
ipcMain.handle('renderer:clear-http-https-agent-cache', async () => {
try {
clearAgentCache();
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.on('renderer:theme-change', (event, theme) => {
nativeTheme.themeSource = theme;
});

View File

@@ -106,6 +106,11 @@ const defaultPreferences = {
},
display: {
zoomPercentage: 100
},
cache: {
sslSession: {
enabled: false
}
}
};
@@ -164,7 +169,12 @@ const preferencesSchema = Yup.object().shape({
}),
display: Yup.object({
zoomPercentage: Yup.number().min(50).max(150)
})
}),
cache: Yup.object({
sslSession: Yup.object({
enabled: Yup.boolean()
})
}).optional()
});
class PreferencesStore {
@@ -351,6 +361,9 @@ const preferencesUtil = {
getZoomPercentage: () => {
return get(getPreferences(), 'display.zoomPercentage', 100);
},
isSslSessionCachingEnabled: () => {
return get(getPreferences(), 'cache.sslSession.enabled', false);
},
hasLaunchedBefore: () => {
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
},

View File

@@ -1,10 +1,13 @@
const parseUrl = require('url').parse;
const https = require('node:https');
const http = require('node:http');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { interpolateString } = require('../ipc/network/interpolate-string');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { isEmpty, get, isUndefined, isNull } = require('lodash');
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { preferencesUtil } = require('../store/preferences');
const DEFAULT_PORTS = {
ftp: 21,
@@ -67,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) {
@@ -78,244 +89,20 @@ 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);
}
}
function createTimelineHttpAgentClass(BaseAgentClass) {
return class extends BaseAgentClass {
constructor(options, timeline) {
// For proxy agents, the first argument is the proxy URI and the second is options
const { proxy: proxyUri, httpProxyAgentOptions } = options || {};
if (!proxyUri) {
throw new Error('TimelineHttpProxyAgent requires options.proxy to be set');
}
super(proxyUri, httpProxyAgentOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
// Log the proxy details
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using proxy: ${proxyUri}`
});
}
};
}
function createTimelineAgentClass(BaseAgentClass) {
return class extends BaseAgentClass {
constructor(options, timeline) {
let caCertificatesCount = options.caCertificatesCount || {};
delete options.caCertificatesCount;
// For proxy agents, the first argument is the proxy URI and the second is options
if (options?.proxy) {
const { proxy: proxyUri, ...agentOptions } = options;
// Ensure TLS options are properly set
const tlsOptions = {
...agentOptions,
rejectUnauthorized: agentOptions.rejectUnauthorized ?? true
};
super(proxyUri, tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!tlsOptions.ca;
// Log TLS verification status
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`
});
// Log the proxy details
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using proxy: ${proxyUri}`
});
} else {
// This is a regular HTTPS agent case
const tlsOptions = {
...options,
rejectUnauthorized: options.rejectUnauthorized ?? true
};
super(tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!options.ca;
// Log TLS verification status
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`
});
}
this.caCertificatesCount = caCertificatesCount;
}
createConnection(options, callback) {
const { host, port } = options;
// Log ALPN protocols offered
if (this.alpnProtocols && this.alpnProtocols.length > 0) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `ALPN: offers ${this.alpnProtocols.join(', ')}`
});
}
const rootCerts = this.caCertificatesCount.root || 0;
const systemCerts = this.caCertificatesCount.system || 0;
const extraCerts = this.caCertificatesCount.extra || 0;
const customCerts = this.caCertificatesCount.custom || 0;
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`
});
// Log "Trying host:port..."
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `Trying ${host}:${port}...`
});
let socket;
try {
socket = super.createConnection(options, callback);
} catch (error) {
this.timeline.push({
timestamp: new Date(),
type: 'error',
message: `Error creating connection: ${error.message}`
});
error.timeline = this.timeline;
throw error;
}
// Attach event listeners to the socket
socket?.on('lookup', (err, address, family, host) => {
if (err) {
this.timeline.push({
timestamp: new Date(),
type: 'error',
message: `DNS lookup error for ${host}: ${err.message}`
});
} else {
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `DNS lookup: ${host} -> ${address}`
});
}
});
socket?.on('connect', () => {
const address = socket.remoteAddress || host;
const remotePort = socket.remotePort || port;
this.timeline.push({
timestamp: new Date(),
type: 'info',
message: `Connected to ${host} (${address}) port ${remotePort}`
});
});
socket?.on('secureConnect', () => {
const protocol = socket.getProtocol() || 'SSL/TLS';
const cipher = socket.getCipher();
const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `SSL connection using ${protocol} / ${cipherSuite}`
});
// ALPN protocol
const alpnProtocol = socket.alpnProtocol || 'None';
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `ALPN: server accepted ${alpnProtocol}`
});
// Server certificate
const cert = socket.getPeerCertificate(true);
if (cert) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `Server certificate:`
});
if (cert.subject) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`
});
}
if (cert.valid_from) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` start date: ${cert.valid_from}`
});
}
if (cert.valid_to) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` expire date: ${cert.valid_to}`
});
}
if (cert.subjectaltname) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` subjectAltName: ${cert.subjectaltname}`
});
}
if (cert.issuer) {
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`
});
}
// SSL certificate verify ok
this.timeline.push({
timestamp: new Date(),
type: 'tls',
message: `SSL certificate verify ok.`
});
}
});
socket?.on('error', (err) => {
this.timeline.push({
timestamp: new Date(),
type: 'error',
message: `Socket error: ${err.message}`
});
});
return socket;
}
};
}
function setupProxyAgents({
requestConfig,
proxyMode = 'off',
@@ -324,6 +111,8 @@ function setupProxyAgents({
interpolationOptions,
timeline
}) {
const disableCache = !preferencesUtil.isSslSessionCachingEnabled();
// Ensure TLS options are properly set
const tlsOptions = {
...httpsAgentRequestFields,
@@ -331,21 +120,22 @@ function setupProxyAgents({
secureProtocol: undefined,
// Allow Node.js to choose the protocol
minVersion: 'TLSv1',
rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true
};
const httpProxyAgentOptions = {
rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true,
// Enable keepAlive for connection reuse
keepAlive: true
};
const parsedUrl = parseUrl(requestConfig.url);
const isHttpsRequest = parsedUrl.protocol === 'https:';
const hostname = parsedUrl.hostname || null;
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthDisabled = get(proxyConfig, 'auth.disabled', false);
const proxyAuthEnabled = !proxyAuthDisabled;
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
@@ -358,35 +148,51 @@ function setupProxyAgents({
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
if (socksEnabled) {
const TimelineSocksProxyAgent = createTimelineAgentClass(SocksProxyAgent);
requestConfig.httpAgent = new TimelineSocksProxyAgent({ proxy: proxyUri }, timeline);
requestConfig.httpsAgent = new TimelineSocksProxyAgent({ proxy: proxyUri, ...tlsOptions }, timeline);
} else {
const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent);
const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: proxyUri, httpProxyAgentOptions }, timeline);
requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
{ proxy: proxyUri, ...tlsOptions },
timeline
);
if (timeline) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using proxy: ${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) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
} else {
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: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname });
}
}
} else {
// If proxy should not be used, set default HTTPS agent
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
} else if (proxyMode === 'system') {
const { http_proxy, https_proxy, no_proxy } = proxyConfig || {};
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
const parsedUrl = parseUrl(requestConfig.url);
const isHttpsRequest = parsedUrl.protocol === 'https:';
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
new URL(http_proxy);
const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent);
requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: http_proxy, httpProxyAgentOptions }, timeline);
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(),
type: 'info',
message: `Using system proxy: ${http_proxy}`
});
}
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}`);
@@ -394,25 +200,27 @@ function setupProxyAgents({
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
{ proxy: https_proxy, ...tlsOptions },
timeline
);
} else {
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
if (timeline) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using system proxy: ${https_proxy}`
});
}
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, timeline, disableCache, hostname });
}
} catch (error) {
throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
}
} else {
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
} else {
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, proxyUri: null, timeline, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, proxyUri: null, timeline, disableCache, hostname });
}
}
}

View File

@@ -9,6 +9,7 @@ export { default as createVaultClient, VaultError } from './utils/node-vault';
export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node-vault';
export { getHttpHttpsAgents } from './utils/http-https-agents';
export { initializeShellEnv } from './utils/shell-env';
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
export * as scripting from './scripting';

View File

@@ -0,0 +1,374 @@
import https from 'node:https';
import http from 'node:http';
import { EventEmitter } from 'node:events';
import { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './agent-cache';
describe('Agent Cache', () => {
beforeEach(() => {
clearAgentCache();
});
describe('getOrCreateHttpsAgent', () => {
it('creates a new agent when cache is empty', () => {
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
expect(agent).toBeInstanceOf(https.Agent);
expect(getAgentCacheSize()).toBe(1);
});
it('returns cached agent for identical options', () => {
const options = { rejectUnauthorized: true, keepAlive: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
expect(agent1).toBe(agent2);
expect(getAgentCacheSize()).toBe(1);
});
it('creates separate agents for different rejectUnauthorized values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different CA certificates', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-a' } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-b' } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different cert values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-a') } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-b') } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different key values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-a') } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-b') } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different pfx values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-a') } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-b') } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different passphrase values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-a' } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-b' } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different proxy URIs', () => {
const options = { rejectUnauthorized: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy1:8080' });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy2:8080' });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different agent classes', () => {
const options = { keepAlive: true };
const httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
const httpAgent = getOrCreateHttpsAgent({ AgentClass: http.Agent, options });
expect(httpsAgent).not.toBe(httpAgent);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different keepAlive values', () => {
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: true } });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: false } });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('creates separate agents for different hostnames', () => {
const options = { rejectUnauthorized: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'auth.example.com' });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
it('returns cached agent for the same hostname', () => {
const options = { rejectUnauthorized: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
expect(agent1).toBe(agent2);
expect(getAgentCacheSize()).toBe(1);
});
it('creates separate agents for null hostname vs explicit hostname', () => {
const options = { rejectUnauthorized: true };
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: null });
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
expect(agent1).not.toBe(agent2);
expect(getAgentCacheSize()).toBe(2);
});
});
describe('timeline support', () => {
it('does not add timeline when none is provided', () => {
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {} }) as any;
expect(agent.timeline).toBeUndefined();
});
it('uses provided timeline array', () => {
const timeline: any[] = [];
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline }) as any;
expect(agent.timeline).toBe(timeline);
});
it('updates timeline reference on cached agents', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
expect(agent1.timeline).toBe(timeline1);
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any;
expect(agent1).toBe(agent2);
expect(agent2.timeline).toBe(timeline2);
});
it('logs when reusing a cached HTTPS agent', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
// First call creates new agent - no reuse message
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 });
expect(timeline1.some((e) => e.message.includes('Reusing cached https agent'))).toBe(false);
// Second call reuses cached agent - should log reuse message with SSL session reuse
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 });
expect(timeline2.some((e) => e.message.includes('Reusing cached https agent'))).toBe(true);
});
it('logs when reusing a cached HTTP agent', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
// First call creates new agent - no reuse message
getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline1 });
expect(timeline1.some((e) => e.message.includes('Reusing cached http agent'))).toBe(false);
// Second call reuses cached agent - should log reuse message with connection reuse
getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline2 });
expect(timeline2.some((e) => e.message.includes('Reusing cached http agent'))).toBe(true);
});
it('logs SSL validation status on agent creation', () => {
const timeline: any[] = [];
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true }, timeline });
const sslEntry = timeline.find((e) => e.message.includes('SSL validation'));
expect(sslEntry).toBeDefined();
expect(sslEntry.message).toContain('enabled');
});
it('logs SSL validation disabled when rejectUnauthorized is false', () => {
const timeline: any[] = [];
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false }, timeline });
const sslEntry = timeline.find((e) => e.message.includes('SSL validation'));
expect(sslEntry).toBeDefined();
expect(sslEntry.message).toContain('disabled');
});
});
describe('clearAgentCache', () => {
it('removes all cached agents', () => {
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } });
expect(getAgentCacheSize()).toBe(2);
clearAgentCache();
expect(getAgentCacheSize()).toBe(0);
});
it('destroys all agents when clearing cache', () => {
const destroyMocks: jest.Mock[] = [];
// Create several agents and attach mock destroy functions
for (let i = 0; i < 5; i++) {
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } }) as any;
const mock = jest.fn();
agent.destroy = mock;
destroyMocks.push(mock);
}
expect(getAgentCacheSize()).toBe(5);
clearAgentCache();
expect(getAgentCacheSize()).toBe(0);
// All agents should have been destroyed
destroyMocks.forEach((mock) => {
expect(mock).toHaveBeenCalled();
});
});
});
describe('LRU eviction', () => {
it('maintains cache size under limit', () => {
// Create many agents with different options
for (let i = 0; i < 150; i++) {
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } });
}
// Cache should be capped at MAX_AGENT_CACHE_SIZE (100)
expect(getAgentCacheSize()).toBeLessThanOrEqual(100);
});
it('destroys evicted agents to prevent memory leaks', () => {
// Create first agent and attach a mock destroy function
const firstAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-to-evict' } }) as any;
const destroyMock = jest.fn();
firstAgent.destroy = destroyMock;
// Fill cache to trigger eviction (100 more agents will evict the first one)
for (let i = 0; i < 100; i++) {
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } });
}
// First agent should have been evicted and destroyed
expect(destroyMock).toHaveBeenCalled();
});
});
describe('concurrent requests timeline isolation', () => {
it('isolates timeline events for concurrent requests using the same cached agent', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
// Get the same agent twice with different timelines (simulating concurrent requests)
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any;
// Both should return the same cached agent
expect(agent1).toBe(agent2);
// Create mock sockets to simulate concurrent connections
const mockSocket1 = new EventEmitter() as any;
mockSocket1.remoteAddress = '1.2.3.4';
mockSocket1.remotePort = 443;
mockSocket1.getProtocol = () => 'TLSv1.3';
mockSocket1.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' });
mockSocket1.alpnProtocol = 'h2';
mockSocket1.getPeerCertificate = () => ({
subject: { CN: 'example.com' },
valid_from: 'Jan 1 00:00:00 2024 GMT',
valid_to: 'Jan 1 00:00:00 2025 GMT'
});
mockSocket1.authorized = true;
const mockSocket2 = new EventEmitter() as any;
mockSocket2.remoteAddress = '5.6.7.8';
mockSocket2.remotePort = 443;
mockSocket2.getProtocol = () => 'TLSv1.3';
mockSocket2.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' });
mockSocket2.alpnProtocol = 'http/1.1';
mockSocket2.getPeerCertificate = () => ({
subject: { CN: 'other.com' },
valid_from: 'Jan 1 00:00:00 2024 GMT',
valid_to: 'Jan 1 00:00:00 2025 GMT'
});
mockSocket2.authorized = true;
// Mock createConnection to return our mock sockets
const originalCreateConnection = Object.getPrototypeOf(Object.getPrototypeOf(agent1)).createConnection;
let callCount = 0;
jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent1)), 'createConnection').mockImplementation(function (this: any, options: any, callback: any) {
callCount++;
return callCount === 1 ? mockSocket1 : mockSocket2;
});
// Simulate request 1 starting - this captures timeline1 in the closure
agent1.timeline = timeline1;
const socket1 = agent1.createConnection({ host: 'example.com', port: 443 }, () => {});
// Before request 1's events fire, request 2 starts and updates agent.timeline
// This simulates the race condition
agent1.timeline = timeline2;
const socket2 = agent1.createConnection({ host: 'other.com', port: 443 }, () => {});
// Now fire events for both sockets - they should go to their respective timelines
mockSocket1.emit('connect');
mockSocket1.emit('secureConnect');
mockSocket2.emit('connect');
mockSocket2.emit('secureConnect');
// Verify timeline1 only contains events for request 1 (example.com)
const timeline1Messages = timeline1.map((e) => e.message);
expect(timeline1Messages.some((m) => m.includes('example.com'))).toBe(true);
expect(timeline1Messages.some((m) => m.includes('other.com'))).toBe(false);
// Verify timeline2 only contains events for request 2 (other.com)
const timeline2Messages = timeline2.map((e) => e.message);
expect(timeline2Messages.some((m) => m.includes('other.com'))).toBe(true);
expect(timeline2Messages.some((m) => m.includes('example.com'))).toBe(false);
// Restore the original implementation
jest.restoreAllMocks();
});
it('logs events to captured timeline even after agent.timeline is reassigned', () => {
const timeline1: any[] = [];
const timeline2: any[] = [];
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
// Create a mock socket
const mockSocket = new EventEmitter() as any;
mockSocket.remoteAddress = '1.2.3.4';
mockSocket.remotePort = 443;
// Mock createConnection
jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent)), 'createConnection').mockImplementation(() => mockSocket);
// Start creating connection - this captures timeline1
const socket = agent.createConnection({ host: 'test.com', port: 443 }, () => {});
// Reassign agent.timeline (simulating another request coming in)
agent.timeline = timeline2;
// Fire the connect event - this should still go to timeline1 (captured reference)
mockSocket.emit('connect');
// Verify event went to timeline1, not timeline2
expect(timeline1.some((e) => e.message.includes('Connected to test.com'))).toBe(true);
expect(timeline2.some((e) => e.message.includes('Connected to test.com'))).toBe(false);
jest.restoreAllMocks();
});
});
});

View File

@@ -0,0 +1,393 @@
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';
/**
* Agent cache for SSL session reuse.
* Agents are cached by their configuration to enable TLS session resumption,
* which significantly reduces SSL handshake time for repeated requests.
*/
const agentCache = new Map<string, HttpAgent | HttpsAgent>();
/**
* Maximum number of agents to cache.
* 100 provides a good balance between memory usage and SSL session reuse.
* Each agent maintains persistent connections, so higher values increase memory.
* Lower values may reduce SSL session hits for users with many different TLS configs.
*/
const MAX_AGENT_CACHE_SIZE = 100;
/**
* Cache for timeline-wrapped HTTPS agent classes.
* Prevents creating new class definitions on every call.
*/
const timelineClassCache = new WeakMap<any, AgentClass>();
/**
* Cache for timeline-wrapped HTTP agent classes.
* Prevents creating new class definitions on every call.
*/
const timelineHttpClassCache = new WeakMap<any, HttpAgentClass>();
/**
* Map to assign unique IDs to agent classes.
* Used for cache key generation since different classes may have the same name.
*/
const agentClassIdMap = new WeakMap<any, number>();
let agentClassIdCounter = 0;
function getAgentClassId(AgentClass: any): number {
if (agentClassIdMap.has(AgentClass)) {
return agentClassIdMap.get(AgentClass)!;
}
const id = ++agentClassIdCounter;
agentClassIdMap.set(AgentClass, id);
return id;
}
/**
* Hash a value using SHA-256 and return a truncated hex string.
* Truncated to 16 chars for compact cache keys while maintaining uniqueness.
*/
function hashValue(value: string | Buffer | undefined): string | null {
if (!value) return null;
const data = Buffer.isBuffer(value) ? value : String(value);
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.
*
* When client certificates (pfx/cert/key) are also present, they are loaded
* into the secure context so they aren't silently ignored by Node.js
* (Node.js skips pfx/cert/key/ca when a secureContext is provided).
*/
function applySecureContext<T extends AgentOptions | HttpAgentOptions>(options: T): T {
if ('ca' in options && (options as AgentOptions).ca) {
const { ca, ...rest } = options as AgentOptions;
// When client certs are present alongside CA, build a combined context
// that includes both. This context can't be CA-cached since it's unique
// per client cert + CA combination.
const hasClientCert = rest.pfx || rest.cert || rest.key;
if (hasClientCert) {
const ctxOptions: Record<string, any> = {};
if (rest.pfx) ctxOptions.pfx = rest.pfx;
if (rest.cert) ctxOptions.cert = rest.cert;
if (rest.key) ctxOptions.key = rest.key;
if (rest.passphrase) ctxOptions.passphrase = rest.passphrase;
const ctx = tls.createSecureContext(ctxOptions);
const caList = Array.isArray(ca) ? ca : [ca!];
for (const caCert of caList) {
if (caCert) ctx.context.addCACert(caCert);
}
const { pfx: _pfx, cert: _cert, key: _key, passphrase: _pass, ...cleanRest } = rest;
return { ...cleanRest, secureContext: ctx } as unknown as T;
}
// CA-only case: use cached secure context
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)[].
*/
function hashCaValue(value: string | Buffer | (string | Buffer)[] | undefined): string | null {
if (!value) return null;
if (Array.isArray(value)) {
// Concatenate all values with separator and hash together
const combined = value.map((v) => (Buffer.isBuffer(v) ? v.toString('base64') : String(v))).join('|');
return crypto.createHash('sha256').update(combined).digest('hex').slice(0, 16);
}
const data = Buffer.isBuffer(value) ? value : String(value);
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
}
/**
* Generate a cache key from HTTPS agent options.
* Uses a hash of the serialized options to create a compact key.
*/
function getAgentCacheKey(agentClassId: number, options: AgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
// Extract the TLS-relevant options for the cache key
const keyData = {
agentClassId,
hostname: proxyUri?.length ? null : hostname,
proxyUri,
keepAlive: options.keepAlive,
rejectUnauthorized: options.rejectUnauthorized,
// Hash certificates and passphrase instead of including full content
ca: hashCaValue(options.ca),
cert: hashValue(options.cert),
key: hashValue(options.key),
pfx: hashValue(options.pfx),
passphrase: hashValue(options.passphrase),
minVersion: options.minVersion,
secureProtocol: options.secureProtocol
};
return JSON.stringify(keyData);
}
/**
* Generate a cache key from HTTP agent options.
* Simpler than HTTPS since no TLS options are involved.
*/
function getHttpAgentCacheKey(agentClassId: number, options: HttpAgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
const keyData = {
agentClassId,
hostname: proxyUri?.length ? null : hostname,
proxyUri,
keepAlive: options.keepAlive
};
return JSON.stringify(keyData);
}
/**
* Get a cached timeline-wrapped HTTPS agent class.
* Creates the wrapped class once and caches it for reuse.
*/
function getTimelineAgentClass(BaseAgentClass: any): AgentClass {
if (timelineClassCache.has(BaseAgentClass)) {
return timelineClassCache.get(BaseAgentClass)!;
}
const wrappedClass = createTimelineAgentClass(BaseAgentClass);
timelineClassCache.set(BaseAgentClass, wrappedClass);
return wrappedClass;
}
/**
* Get a cached timeline-wrapped HTTP agent class.
* Creates the wrapped class once and caches it for reuse.
*/
function getTimelineHttpAgentClass(BaseAgentClass: any): HttpAgentClass {
if (timelineHttpClassCache.has(BaseAgentClass)) {
return timelineHttpClassCache.get(BaseAgentClass)!;
}
const wrappedClass = createTimelineHttpAgentClass(BaseAgentClass);
timelineHttpClassCache.set(BaseAgentClass, wrappedClass);
return wrappedClass;
}
/**
* Type for cache key generation functions.
*/
type CacheKeyFn<T> = (classId: number, options: T, proxyUri: string | null, hostname: string | null) => string;
/**
* Type for timeline class wrapper functions.
*/
type TimelineClassFn = (base: any) => AgentClass | HttpAgentClass;
/**
* Internal helper for agent caching with LRU eviction.
* Shared logic for both HTTP and HTTPS agents.
*/
function getOrCreateAgentInternal<TOptions extends HttpAgentOptions>(
BaseAgentClass: any,
options: TOptions,
proxyUri: string | null,
timeline: TimelineEntry[] | null,
getCacheKey: CacheKeyFn<TOptions>,
getTimelineClass: TimelineClassFn,
cacheHitMessage: string,
disableCache: boolean = false,
hostname: string | null = null
): HttpAgent | HttpsAgent {
const agentClassId = getAgentClassId(BaseAgentClass);
const cacheKey = getCacheKey(agentClassId, options, proxyUri, hostname);
if (!disableCache && agentCache.has(cacheKey)) {
// Move to end for LRU (delete and re-add)
const agent = agentCache.get(cacheKey)!;
agentCache.delete(cacheKey);
agentCache.set(cacheKey, agent);
// Update timeline reference for new request
// The cached agent was created with a previous timeline,
// but we need events to go to the current request's timeline
if (timeline && 'timeline' in agent) {
(agent as any).timeline = timeline;
}
// Log that we're reusing a cached agent
if (timeline) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: cacheHitMessage
});
}
return agent;
}
const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass;
// 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)
if (agentCache.size >= MAX_AGENT_CACHE_SIZE) {
const firstKey = agentCache.keys().next().value;
if (firstKey !== undefined) {
const evictedAgent = agentCache.get(firstKey);
agentCache.delete(firstKey);
// Destroy the agent to release its sockets and prevent memory leaks
if (evictedAgent && typeof (evictedAgent as any).destroy === 'function') {
(evictedAgent as any).destroy();
}
}
}
agentCache.set(cacheKey, agent);
}
return agent;
}
/**
* Get or create a cached HTTPS agent.
* Reuses existing agents to enable SSL session caching.
* Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
* Automatically wraps the agent class with timeline logging support.
*/
function getOrCreateHttpsAgent({
AgentClass,
options,
proxyUri = null,
timeline = null,
disableCache = false,
hostname = null
}: {
AgentClass: any;
options: AgentOptions;
proxyUri?: string | null;
timeline?: TimelineEntry[] | null;
disableCache?: boolean;
hostname?: string | null;
}): HttpAgent | HttpsAgent {
return getOrCreateAgentInternal(
AgentClass,
options,
proxyUri,
timeline,
getAgentCacheKey,
getTimelineAgentClass,
'Reusing cached https agent',
disableCache,
hostname
);
}
/**
* Get or create a cached HTTP agent.
* Reuses existing agents to enable connection reuse.
* Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
* Automatically wraps the agent class with timeline logging support.
*/
function getOrCreateHttpAgent({
AgentClass,
options,
proxyUri = null,
timeline = null,
disableCache = false,
hostname = null
}: {
AgentClass: any;
options: HttpAgentOptions;
proxyUri?: string | null;
timeline?: TimelineEntry[] | null;
disableCache?: boolean;
hostname?: string | null;
}): HttpAgent {
return getOrCreateAgentInternal(
AgentClass,
options,
proxyUri,
timeline,
getHttpAgentCacheKey,
getTimelineHttpAgentClass,
'Reusing cached http agent',
disableCache,
hostname
) as HttpAgent;
}
/**
* Clear the agent cache. Useful for testing or when SSL configuration changes.
* Destroys all cached agents to properly release their sockets.
*/
function clearAgentCache(): void {
for (const agent of agentCache.values()) {
if (agent && typeof (agent as any).destroy === 'function') {
(agent as any).destroy();
}
}
agentCache.clear();
}
/**
* Get the current size of the agent cache.
*/
function getAgentCacheSize(): number {
return agentCache.size;
}
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize };

View File

@@ -1,5 +1,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import http from 'node:http';
import https from 'node:https';
import type { Agent as HttpAgent } from 'node:http';
import type { Agent as HttpsAgent } from 'node:https';
@@ -10,6 +11,8 @@ import { HttpProxyAgent } from 'http-proxy-agent';
import { isEmpty, get, isUndefined, isNull } from 'lodash';
import { getCACertificates } from './ca-cert';
import { transformProxyConfig } from './proxy-util';
import { getOrCreateHttpsAgent, getOrCreateHttpAgent } from './agent-cache';
import type { TimelineEntry } from './timeline-agent';
const DEFAULT_PORTS: Record<string, number> = {
ftp: 21,
@@ -93,6 +96,7 @@ type ConfigOptions = {
shouldUseCustomCaCertificate: boolean;
customCaCertificateFilePath?: string;
shouldKeepDefaultCaCertificates: boolean;
cacheSslSession?: boolean;
};
type GetCertsAndProxyConfigParams = {
@@ -120,6 +124,8 @@ type CreateAgentsParams = {
certsConfig: CertsConfig;
httpsAgentRequestFields: HttpsAgentRequestFields;
systemProxyConfig?: SystemProxyConfig;
timeline?: TimelineEntry[];
disableCache?: boolean;
};
type GetHttpHttpsAgentsParams = {
@@ -132,6 +138,7 @@ type GetHttpHttpsAgentsParams = {
collectionLevelProxy?: ProxyConfig;
appLevelProxyConfig?: Record<string, any>;
systemProxyConfig?: SystemProxyConfig;
timeline?: TimelineEntry[];
};
/**
@@ -188,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;
@@ -201,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);
}
}
@@ -336,13 +365,24 @@ const getCertsAndProxyConfig = ({
return { proxyMode, proxyConfig, certsConfig };
};
function extractHostname(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).hostname || null;
} catch {
return null;
}
}
function createAgents({
requestUrl,
proxyMode,
proxyConfig,
systemProxyConfig,
certsConfig,
httpsAgentRequestFields
httpsAgentRequestFields,
timeline,
disableCache = true
}: CreateAgentsParams): AgentResult {
// Ensure TLS options are properly set
const tlsOptions: TlsOptions = {
@@ -358,13 +398,19 @@ function createAgents({
let httpAgent: HttpAgent | undefined;
let httpsAgent: HttpsAgent | HttpsProxyAgent<any> | SocksProxyAgent | undefined;
// Determine if this is an HTTPS request
const isHttpsRequest = requestUrl ? requestUrl.startsWith('https:') : true;
// Extract hostname for per-host agent caching (enables TLS session reuse per host)
const hostname = extractHostname(requestUrl);
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = get(proxyConfig, 'protocol');
const proxyHostname = get(proxyConfig, 'hostname');
const proxyPort = get(proxyConfig, 'port');
const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol && proxyProtocol.includes('socks');
if (!proxyProtocol || !proxyHostname) {
@@ -381,16 +427,31 @@ 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) {
httpAgent = new SocksProxyAgent(proxyUri);
httpsAgent = new SocksProxyAgent(proxyUri, tlsOptions as any);
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
}
} else {
httpAgent = new HttpProxyAgent(proxyUri);
httpsAgent = new PatchedHttpsProxyAgent(proxyUri, tlsOptions);
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
}
}
} else {
// If proxy should not be used, set default HTTPS agent
httpsAgent = new https.Agent(tlsOptions as any);
// If proxy should not be used, only set HTTPS agent for HTTPS requests
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
}
// HTTP requests without proxy don't need a custom agent
}
} else if (proxyMode === 'system') {
const http_proxy = get(systemProxyConfig, 'http_proxy');
@@ -399,28 +460,32 @@ function createAgents({
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length) {
new URL(http_proxy);
httpAgent = new HttpProxyAgent(http_proxy);
if (http_proxy?.length && !isHttpsRequest) {
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');
}
try {
if (https_proxy?.length) {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
httpsAgent = new PatchedHttpsProxyAgent(https_proxy, tlsOptions as any);
} else {
httpsAgent = new https.Agent(tlsOptions as any);
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
} else {
httpsAgent = new https.Agent(tlsOptions as any);
}
} else {
httpsAgent = new https.Agent(tlsOptions as any);
}
if (!httpAgent && !httpsAgent) {
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline || null, disableCache, hostname });
}
}
return { httpAgent, httpsAgent };
@@ -433,7 +498,8 @@ const getHttpHttpsAgents = async ({
collectionLevelProxy,
appLevelProxyConfig,
systemProxyConfig,
options
options,
timeline
}: GetHttpHttpsAgentsParams): Promise<AgentResult> => {
const { proxyMode, proxyConfig, certsConfig } = getCertsAndProxyConfig({
requestUrl,
@@ -460,7 +526,9 @@ const getHttpHttpsAgents = async ({
proxyConfig,
systemProxyConfig,
certsConfig,
httpsAgentRequestFields
httpsAgentRequestFields,
timeline,
disableCache: !options.cacheSslSession
});
return { httpAgent, httpsAgent };

View File

@@ -0,0 +1,309 @@
import http from 'node:http';
import https from 'node:https';
type TimelineEntry = {
timestamp: Date;
type: 'info' | 'tls' | 'error';
message: string;
};
type CaCertificatesCount = {
root?: number;
system?: number;
extra?: number;
custom?: number;
};
type AgentOptions = {
rejectUnauthorized?: boolean;
ca?: string | string[] | Buffer | Buffer[];
cert?: string | Buffer;
key?: string | Buffer;
pfx?: string | Buffer;
passphrase?: string;
minVersion?: string;
secureProtocol?: string;
keepAlive?: boolean;
ALPNProtocols?: string[];
caCertificatesCount?: CaCertificatesCount;
proxy?: string;
secureContext?: any;
};
type AgentClass = new (options: AgentOptions, timeline?: TimelineEntry[]) => https.Agent;
type ProxyAgentClass = new (proxyUri: string, options?: AgentOptions) => https.Agent;
type HttpAgentOptions = {
keepAlive?: boolean;
proxy?: string;
};
type HttpAgentClass = new (options: HttpAgentOptions, timeline?: TimelineEntry[]) => http.Agent;
type HttpProxyAgentClass = new (proxyUri: string, options?: HttpAgentOptions) => http.Agent;
/**
* Creates a timeline-aware agent class that logs TLS connection events.
* The returned class wraps the base agent and adds timeline logging for:
* - SSL validation status
* - Proxy usage
* - ALPN protocol negotiation
* - CA certificates info
* - DNS lookups
* - Connection establishment
* - TLS handshake details
* - Server certificate info
*/
function createTimelineAgentClass<T extends ProxyAgentClass | typeof https.Agent>(BaseAgentClass: T): AgentClass {
return class TimelineAgent extends (BaseAgentClass as any) {
timeline: TimelineEntry[];
alpnProtocols: string[];
caProvided: boolean;
caCertificatesCount: CaCertificatesCount;
/**
* Helper method to log entries to the timeline.
*/
private log(type: 'info' | 'tls' | 'error', message: string): void {
this.timeline.push({
timestamp: new Date(),
type,
message
});
}
constructor(options: AgentOptions, timeline?: TimelineEntry[]) {
const caCertificatesCount = options.caCertificatesCount || {};
const optionsCopy = { ...options };
delete optionsCopy.caCertificatesCount;
// For proxy agents, the first argument is the proxy URI and the second is options
if (optionsCopy?.proxy) {
const { proxy: proxyUri, ...agentOptions } = optionsCopy;
// Ensure TLS options are properly set
const tlsOptions = {
...agentOptions,
rejectUnauthorized: agentOptions.rejectUnauthorized ?? true
};
super(proxyUri, tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!(tlsOptions.ca || tlsOptions.secureContext);
// Log TLS verification status and proxy details
this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
this.log('info', `Using proxy: ${proxyUri}`);
} else {
// This is a regular HTTPS agent case
const tlsOptions = {
...optionsCopy,
rejectUnauthorized: optionsCopy.rejectUnauthorized ?? true
};
super(tlsOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
this.alpnProtocols = optionsCopy.ALPNProtocols || ['h2', 'http/1.1'];
this.caProvided = !!(optionsCopy.ca || optionsCopy.secureContext);
// Log TLS verification status
this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
}
this.caCertificatesCount = caCertificatesCount;
}
createConnection(options: any, callback: any) {
const { host, port } = options;
// Capture the current timeline reference to avoid race conditions
// when multiple concurrent requests reuse the same cached agent
const timeline = this.timeline;
const log = (type: 'info' | 'tls' | 'error', message: string): void => {
timeline.push({
timestamp: new Date(),
type,
message
});
};
// Log ALPN protocols offered
if (this.alpnProtocols && this.alpnProtocols.length > 0) {
log('tls', `ALPN: offers ${this.alpnProtocols.join(', ')}`);
}
const rootCerts = this.caCertificatesCount.root || 0;
const systemCerts = this.caCertificatesCount.system || 0;
const extraCerts = this.caCertificatesCount.extra || 0;
const customCerts = this.caCertificatesCount.custom || 0;
log('tls', `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`);
// Log "Trying host:port..."
log('info', `Trying ${host}:${port}...`);
let socket: any;
try {
socket = super.createConnection(options, callback);
} catch (error: any) {
log('error', `Error creating connection: ${error.message}`);
error.timeline = timeline;
throw error;
}
// Attach event listeners to the socket
socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => {
if (err) {
log('error', `DNS lookup error for ${host}: ${err.message}`);
} else {
log('info', `DNS lookup: ${host} -> ${address}`);
}
});
socket?.on('connect', () => {
const address = socket.remoteAddress || host;
const remotePort = socket.remotePort || port;
log('info', `Connected to ${host} (${address}) port ${remotePort}`);
});
socket?.on('secureConnect', () => {
const protocol = socket.getProtocol?.() || 'SSL/TLS';
const cipher = socket.getCipher?.();
const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
log('tls', `SSL connection using ${protocol} / ${cipherSuite}`);
// ALPN protocol
const alpnProtocol = socket.alpnProtocol || 'None';
log('tls', `ALPN: server accepted ${alpnProtocol}`);
// Server certificate
const cert = socket.getPeerCertificate?.(true);
if (cert) {
log('tls', `Server certificate:`);
if (cert.subject) {
log('tls', ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`);
}
if (cert.valid_from) {
log('tls', ` start date: ${cert.valid_from}`);
}
if (cert.valid_to) {
log('tls', ` expire date: ${cert.valid_to}`);
}
if (cert.subjectaltname) {
log('tls', ` subjectAltName: ${cert.subjectaltname}`);
}
if (cert.issuer) {
log('tls', ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`);
}
// SSL certificate verification status
if (socket.authorized !== false) {
log('tls', `SSL certificate verify ok.`);
} else {
log('tls', `SSL certificate verification skipped (rejectUnauthorized: false).`);
}
}
});
socket?.on('error', (err: Error) => {
log('error', `Socket error: ${err.message}`);
});
return socket;
}
} as unknown as AgentClass;
}
/**
* Creates a timeline-aware HTTP agent class that logs connection events.
* The returned class wraps the base HTTP agent and adds timeline logging for:
* - Proxy usage (when applicable)
* - DNS lookups
* - Connection establishment
* - Errors
*
* This is a simplified version of createTimelineAgentClass for HTTP (non-TLS) connections.
*/
function createTimelineHttpAgentClass<T extends HttpProxyAgentClass | typeof http.Agent>(BaseAgentClass: T): HttpAgentClass {
return class TimelineHttpAgent extends (BaseAgentClass as any) {
timeline: TimelineEntry[];
/**
* Helper method to log entries to the timeline.
*/
private log(type: 'info' | 'tls' | 'error', message: string): void {
this.timeline.push({
timestamp: new Date(),
type,
message
});
}
constructor(options: HttpAgentOptions, timeline?: TimelineEntry[]) {
const optionsCopy = { ...options };
// For proxy agents, the first argument is the proxy URI and the second is options
if (optionsCopy?.proxy) {
const { proxy: proxyUri, ...agentOptions } = optionsCopy;
super(proxyUri, agentOptions);
this.timeline = Array.isArray(timeline) ? timeline : [];
// Log proxy details
this.log('info', `Using proxy: ${proxyUri}`);
} else {
super(optionsCopy);
this.timeline = Array.isArray(timeline) ? timeline : [];
}
}
createConnection(options: any, callback: any) {
const { host, port } = options;
// Capture the current timeline reference to avoid race conditions
// when multiple concurrent requests reuse the same cached agent
const timeline = this.timeline;
const log = (type: 'info' | 'tls' | 'error', message: string): void => {
timeline.push({
timestamp: new Date(),
type,
message
});
};
// Log "Trying host:port..."
log('info', `Trying ${host}:${port}...`);
let socket: any;
try {
socket = super.createConnection(options, callback);
} catch (error: any) {
log('error', `Error creating connection: ${error.message}`);
error.timeline = timeline;
throw error;
}
// Attach event listeners to the socket
socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => {
if (err) {
log('error', `DNS lookup error for ${host}: ${err.message}`);
} else {
log('info', `DNS lookup: ${host} -> ${address}`);
}
});
socket?.on('connect', () => {
const address = socket.remoteAddress || host;
const remotePort = socket.remotePort || port;
log('info', `Connected to ${host} (${address}) port ${remotePort}`);
});
socket?.on('error', (err: Error) => {
log('error', `Socket error: ${err.message}`);
});
return socket;
}
} as unknown as HttpAgentClass;
}
export { createTimelineAgentClass, createTimelineHttpAgentClass, TimelineEntry, AgentOptions, HttpAgentOptions, CaCertificatesCount, AgentClass, HttpAgentClass, ProxyAgentClass, HttpProxyAgentClass };