fix: honor OS-level PAC configuration in system proxy mode (#7766)

This commit is contained in:
Pooja
2026-05-27 14:04:00 +05:30
committed by GitHub
parent 6b7e5f3813
commit 413697cbe7
29 changed files with 680 additions and 223 deletions

View File

@@ -148,7 +148,7 @@ const getCertsAndProxyConfig = async ({
} else if (globalProxySource === 'inherit') {
proxyMode = 'system';
const systemProxyConfig = await getCachedSystemProxy();
proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, source: 'cache-miss' };
proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, pac_url: null, source: 'cache-miss' };
} else {
// source === 'manual'
proxyConfig = globalProxyConfigData;

View File

@@ -12,6 +12,7 @@ const loadSystemProxy = async () => {
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: null,
source: 'error'
};
}

View File

@@ -1,14 +1,17 @@
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 {
getOrCreateHttpsAgent,
getOrCreateHttpAgent,
resolveAgentsFromPac,
PatchedHttpsProxyAgent
} = require('@usebruno/requests');
const { preferencesUtil } = require('../store/preferences');
const { getPacResolver } = require('@usebruno/requests');
const DEFAULT_PORTS = {
ftp: 21,
@@ -70,40 +73,6 @@ const shouldUseProxy = (url, proxyBypass) => {
});
};
/**
* 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) {
super(proxy, opts);
this.constructorOpts = opts;
}
async connect(req, opts) {
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);
}
}
async function setupProxyAgents({
requestConfig,
proxyMode = 'off',
@@ -184,40 +153,58 @@ async function setupProxyAgents({
}
}
} else if (proxyMode === 'system') {
const { http_proxy, https_proxy, no_proxy } = proxyConfig || {};
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
if (shouldUseSystemProxy) {
const { http_proxy, https_proxy, no_proxy, pac_url } = proxyConfig || {};
// If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
if (pac_url) {
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving system PAC: ${pac_url}` });
try {
if (http_proxy?.length && !isHttpsRequest) {
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 });
const { directives, httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname });
if (httpAgent) requestConfig.httpAgent = httpAgent;
if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
if (directives) {
if (timeline) { timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` }); }
} else {
if (timeline) { timeline.push({ timestamp: new Date(), type: 'info', message: 'System PAC resolved: DIRECT (no proxy)' }); }
}
} catch (error) {
throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
} catch (err) {
if (timeline) { timeline.push({ timestamp: new Date(), type: 'error', message: `System PAC resolution failed: ${err.message}` }); }
}
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
if (timeline) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using system proxy: ${https_proxy}`
});
} else {
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 ? { 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 });
}
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, timeline, disableCache, hostname });
} catch (error) {
throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
}
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
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}`);
}
} catch (error) {
throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
}
}
} else if (proxyMode === 'pac') {
@@ -225,26 +212,11 @@ async function setupProxyAgents({
if (pacSource) {
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving PAC: ${pacSource}` });
try {
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields });
const directives = await resolver.resolve(requestConfig.url);
if (directives && directives.length) {
const first = directives[0];
const { directives, httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname });
if (httpAgent) requestConfig.httpAgent = httpAgent;
if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
if (directives) {
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` });
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
const parts = first.split(/\s+/);
const keyword = parts[0].toUpperCase();
const hostPort = parts[1];
const scheme = keyword === 'HTTPS' ? 'https' : 'http';
const proxyUri = `${scheme}://${hostPort}`;
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
} else if (/^SOCKS/i.test(first)) {
const hostPort = first.split(/\s+/)[1];
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
const proxyUri = `${proto}://${hostPort}`;
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
}
} else {
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: 'PAC resolved: DIRECT (no proxy)' });
}

View File

@@ -12,16 +12,51 @@ const setupMocks = ({ pacDirectives = ['PROXY p.example:8080'] } = {}) => {
}
}));
// @usebruno/requests — agent factories + pac resolver
jest.doMock('@usebruno/requests', () => ({
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
getPacResolver: jest.fn(async () => ({
// @usebruno/requests — agent factories + pac resolver + shared resolveAgentsFromPac
jest.doMock('@usebruno/requests', () => {
const getOrCreateHttpsAgent = jest.fn(() => ({ type: 'https-agent' }));
const getOrCreateHttpAgent = jest.fn(() => ({ type: 'http-agent' }));
const getPacResolver = jest.fn(async () => ({
resolve: async () => pacDirectives,
dispose: () => {}
})),
clearPacCache: jest.fn()
}));
}));
// Inline mock of resolveAgentsFromPac that wires through the mocked factories
// so existing assertions on getOrCreateHttp(s)Agent call args still hold.
const resolveAgentsFromPac = jest.fn(async ({ pacSource, requestUrl, tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname }) => {
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields });
const directives = await resolver.resolve(requestUrl);
if (!directives || !directives.length) return { directives: null };
const first = directives[0];
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
const parts = first.split(/\s+/);
const scheme = parts[0].toUpperCase() === 'HTTPS' ? 'https' : 'http';
const proxyUri = `${scheme}://${parts[1]}`;
return {
directives,
httpAgent: getOrCreateHttpAgent({ proxyUri, options: { keepAlive: true }, timeline, disableCache, hostname }),
httpsAgent: getOrCreateHttpsAgent({ proxyUri, options: tlsOptions, timeline, disableCache, hostname })
};
}
if (/^SOCKS/i.test(first)) {
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
const proxyUri = `${proto}://${first.split(/\s+/)[1]}`;
return {
directives,
httpAgent: getOrCreateHttpAgent({ proxyUri, options: { keepAlive: true }, timeline, disableCache, hostname }),
httpsAgent: getOrCreateHttpsAgent({ proxyUri, options: tlsOptions, timeline, disableCache, hostname })
};
}
return { directives };
});
return {
getOrCreateHttpsAgent,
getOrCreateHttpAgent,
getPacResolver,
resolveAgentsFromPac,
PatchedHttpsProxyAgent: class {},
clearPacCache: jest.fn()
};
});
};
describe('proxy-util', () => {
@@ -120,6 +155,8 @@ describe('proxy-util', () => {
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
getPacResolver: jest.fn(async () => { throw new Error('PAC fetch timeout'); }),
resolveAgentsFromPac: jest.fn(async () => { throw new Error('PAC fetch timeout'); }),
PatchedHttpsProxyAgent: class {},
clearPacCache: jest.fn()
}));