fix: recreate HTTP/HTTPS agents on redirect to prevent stale agent reuse (#7597) (#7615)

When a request redirected from HTTP to HTTPS (or vice versa), the
original httpAgent/httpsAgent leaked into the redirect config. The
httpsAgent — which carries custom CA certificates and TLS options — was
never created for the redirect URL, causing UNABLE_TO_VERIFY_LEAF_SIGNATURE.

Changes:
- setupProxyAgents (electron) now deletes stale agents at the top of
  every call so they are always recreated for the current URL
- setupProxyAgents extracted to bruno-cli/proxy-util.js (mirrors the
  electron version) and called on every redirect in the CLI path
- Removed the else-branch in bruno-requests/http-https-agents.ts that
  only created one agent based on initial protocol
- Added HTTP→HTTPS redirect test server and request to the
  custom-ca-certs SSL test suite
This commit is contained in:
lohit
2026-04-01 15:25:28 +00:00
committed by GitHub
parent c502f959b4
commit 0b3f5100e7
11 changed files with 203 additions and 117 deletions

View File

@@ -2,20 +2,16 @@ const qs = require('qs');
const chalk = require('chalk');
const decomment = require('decomment');
const fs = require('fs');
const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash');
const { forOwn, each, extend, get, compact } = require('lodash');
const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
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('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');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const { setupProxyAgents } = require('../utils/proxy-util');
const path = require('path');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
@@ -23,7 +19,7 @@ const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2, applyOAuth1ToRequest } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
const tokenStore = require('../store/tokenStore');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
@@ -429,90 +425,15 @@ 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) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} 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: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
} else {
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
}
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
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');
}
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
}
} 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 });
}
}
setupProxyAgents({
requestConfig: request,
proxyMode,
proxyConfig,
systemProxyConfig: cachedSystemProxy,
httpsAgentRequestFields,
interpolationOptions,
disableCache
});
// set cookies if enabled
if (!options.disableCookies) {
@@ -693,7 +614,13 @@ const runSingleRequest = async function (
let axiosInstance = makeAxiosInstance({
requestMaxRedirects: requestMaxRedirects,
disableCookies: options.disableCookies,
followRedirects: followRedirects
followRedirects: followRedirects,
proxyMode,
proxyConfig,
systemProxyConfig: cachedSystemProxy,
httpsAgentRequestFields,
interpolationOptions,
disableCache
});
if (request.ntlmConfig) {

View File

@@ -2,6 +2,7 @@ const axios = require('axios');
const { CLI_VERSION } = require('../constants');
const { addCookieToJar, getCookieStringForUrl } = require('./cookies');
const { createFormData } = require('./form-data');
const { setupProxyAgents } = require('./proxy-util');
const redirectResponseCodes = [301, 302, 303, 307, 308];
const METHOD_CHANGING_REDIRECTS = [301, 302, 303];
@@ -71,7 +72,17 @@ const createRedirectConfig = (error, redirectUrl) => {
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedirects = true } = {}) {
function makeAxiosInstance({
requestMaxRedirects = 5,
disableCookies,
followRedirects = true,
proxyMode,
proxyConfig,
systemProxyConfig,
httpsAgentRequestFields,
interpolationOptions,
disableCache
} = {}) {
let redirectCount = 0;
/** @type {axios.AxiosInstance} */
@@ -167,6 +178,16 @@ function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedi
const requestConfig = createRedirectConfig(error, redirectUrl);
setupProxyAgents({
requestConfig,
proxyMode,
proxyConfig,
systemProxyConfig,
httpsAgentRequestFields,
interpolationOptions,
disableCache
});
if (!disableCookies) {
const cookieString = getCookieStringForUrl(redirectUrl);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {

View File

@@ -1,6 +1,12 @@
const parseUrl = require('url').parse;
const { isEmpty } = require('lodash');
const http = require('node:http');
const https = require('node:https');
const { isEmpty, get, isUndefined, isNull } = require('lodash');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { interpolateString } = require('../runner/interpolate-string');
const DEFAULT_PORTS = {
ftp: 21,
@@ -96,7 +102,103 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
}
function setupProxyAgents({
requestConfig,
proxyMode = 'off',
proxyConfig,
systemProxyConfig,
httpsAgentRequestFields,
interpolationOptions,
disableCache = true
}) {
// Clear stale agents so we always recreate them for the current URL
// (handles protocol switches, host changes, and proxy-bypass rules on redirects).
delete requestConfig.httpAgent;
delete requestConfig.httpsAgent;
const tlsOptions = { ...httpsAgentRequestFields };
const httpAgentOptions = { keepAlive: true };
const parsedRequestUrl = new URL(requestConfig.url);
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
const hostname = parsedRequestUrl.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 proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol?.includes('socks') ?? false;
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} 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) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
} else {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
}
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = systemProxyConfig || {};
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
const parsedHttpProxy = new URL(http_proxy);
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
}
} catch (error) {}
}
if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
}
}
}
module.exports = {
shouldUseProxy,
PatchedHttpsProxyAgent
PatchedHttpsProxyAgent,
setupProxyAgents
};

View File

@@ -111,6 +111,11 @@ function setupProxyAgents({
interpolationOptions,
timeline
}) {
// Clear stale agents so we always recreate them for the current URL
// (handles protocol switches, host changes, and proxy-bypass rules on redirects).
delete requestConfig.httpAgent;
delete requestConfig.httpsAgent;
const disableCache = !preferencesUtil.isSslSessionCachingEnabled();
// Ensure TLS options are properly set

View File

@@ -446,12 +446,6 @@ function createAgents({
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
}
}
} else {
// 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');

View File

@@ -0,0 +1,16 @@
meta {
name: http-to-https-redirect
type: http
seq: 7
}
get {
url: http://localhost:8091
body: none
auth: inherit
}
assert {
res.status: eq 200
res.body: eq helloworld
}

View File

@@ -2,6 +2,7 @@
const path = require('node:path');
const fs = require('node:fs');
const http = require('node:http');
const https = require('node:https');
const WebSocket = require('ws');
const { killProcessOnPort } = require('./helpers/platform');
@@ -79,6 +80,21 @@ function createServer(certsDir, port = 8090) {
});
}
function createHttpRedirectServer(httpsPort, httpPort = 8091) {
const server = http.createServer((req, res) => {
const redirectUrl = `https://localhost:${httpsPort}${req.url}`;
res.writeHead(301, { Location: redirectUrl });
res.end();
});
return new Promise((resolve, reject) => {
server.listen(httpPort, (error) => {
if (error) reject(error);
else resolve(server);
});
});
}
function shutdownServer(server, cleanup) {
const shutdown = (signal) => {
console.log(`🛑 Received ${signal}, shutting down`);
@@ -104,11 +120,16 @@ async function startServer() {
try {
killProcessOnPort(port);
killProcessOnPort(8091);
console.log(`🌐 Creating server on port ${port}`);
const server = await createServer(certsDir, port);
console.log(`🌐 Creating HTTP redirect server on port 8091 → ${port}`);
const httpRedirectServer = await createHttpRedirectServer(port);
shutdownServer(server, () => {
httpRedirectServer.close();
console.log('✨ Server cleanup completed');
});
} catch (error) {

View File

@@ -13,8 +13,8 @@ test.describe('custom invalid ca cert added to the config and keep default ca ce
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});
@@ -31,8 +31,8 @@ test.describe('custom invalid ca cert added to the config and keep default ca ce
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});

View File

@@ -13,9 +13,9 @@ test.describe.serial('custom invalid ca cert added to the config and NO default
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
totalRequests: 2,
passed: 0,
failed: 1,
failed: 2,
skipped: 0
});
});
@@ -31,9 +31,9 @@ test.describe.serial('custom invalid ca cert added to the config and NO default
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
totalRequests: 2,
passed: 0,
failed: 1,
failed: 2,
skipped: 0
});
});

View File

@@ -13,8 +13,8 @@ test.describe('custom valid ca cert added to the config and keep default ca cert
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});
@@ -31,8 +31,8 @@ test.describe('custom valid ca cert added to the config and keep default ca cert
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});

View File

@@ -13,8 +13,8 @@ test.describe('custom valid ca cert added to the config and NO default ca certs'
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});
@@ -31,8 +31,8 @@ test.describe('custom valid ca cert added to the config and NO default ca certs'
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});