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

@@ -7,7 +7,7 @@ import StyledWrapper from '../StyledWrapper';
const SystemProxy = () => {
const dispatch = useDispatch();
const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables);
const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {};
const { source, http_proxy, https_proxy, no_proxy, pac_url } = systemProxyVariables || {};
const [isFetching, setIsFetching] = useState(true);
const [error, setError] = useState(null);
@@ -85,6 +85,12 @@ const SystemProxy = () => {
</label>
<div className="system-proxy-value">{no_proxy || '-'}</div>
</div>
<div className="mb-1 flex items-center">
<label className="settings-label">
pac_url
</label>
<div className="system-proxy-value">{pac_url || '-'}</div>
</div>
</div>
<span
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"

View File

@@ -421,8 +421,8 @@ const runSingleRequest = async function (
} else if (!collectionProxyDisabled && collectionProxyInherit) {
// Inherit from system proxy
if (cachedSystemProxy) {
const { http_proxy, https_proxy } = cachedSystemProxy;
if (http_proxy?.length || https_proxy?.length) {
const { http_proxy, https_proxy, pac_url } = cachedSystemProxy;
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
proxyMode = 'system';
}
}
@@ -430,7 +430,7 @@ const runSingleRequest = async function (
}
// else: collection proxy is disabled, proxyMode stays 'off'
setupProxyAgents({
await setupProxyAgents({
requestConfig: request,
proxyMode,
proxyConfig,

View File

@@ -139,7 +139,7 @@ function makeAxiosInstance({
return response;
},
(error) => {
async (error) => {
if (error.response) {
const end = Date.now();
const start = error.config.headers['request-start-time'];
@@ -179,7 +179,7 @@ function makeAxiosInstance({
const requestConfig = createRedirectConfig(error, redirectUrl);
setupProxyAgents({
await setupProxyAgents({
requestConfig,
proxyMode,
proxyConfig,

View File

@@ -2,10 +2,14 @@ const parseUrl = require('url').parse;
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 {
getOrCreateHttpsAgent,
getOrCreateHttpAgent,
resolveAgentsFromPac,
PatchedHttpsProxyAgent
} = require('@usebruno/requests');
const { interpolateString } = require('../runner/interpolate-string');
const DEFAULT_PORTS = {
@@ -68,41 +72,7 @@ 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);
}
}
function setupProxyAgents({
async function setupProxyAgents({
requestConfig,
proxyMode = 'off',
proxyConfig,
@@ -163,26 +133,36 @@ function setupProxyAgents({
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = systemProxyConfig || {};
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
if (shouldUseSystemProxy) {
const { http_proxy, https_proxy, no_proxy, pac_url } = systemProxyConfig || {};
// If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
if (pac_url) {
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 });
const { httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, disableCache, hostname });
if (httpAgent) requestConfig.httpAgent = httpAgent;
if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
} catch (error) {}
} 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 ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
} 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 });
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) {
throw new Error('Invalid system https_proxy');
}
}
} catch (error) {}

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

View File

@@ -7,7 +7,7 @@ export { getCACertificates } from './utils/ca-cert';
export { transformProxyConfig } from './utils/proxy-util';
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 { getHttpHttpsAgents, resolveAgentsFromPac, PatchedHttpsProxyAgent } from './utils/http-https-agents';
export { initializeShellEnv } from './utils/shell-env';
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
export { getPacResolver, clearPacCache } from './utils/pac-resolver';

View File

@@ -181,6 +181,7 @@ describe('SystemProxyResolver Integration', () => {
http_proxy: 'http://env-proxy.usebruno.com:9090',
https_proxy: 'https://system-proxy.usebruno.com:8443',
no_proxy: 'localhost',
pac_url: null,
source: 'windows-system + environment'
});
});
@@ -209,6 +210,7 @@ describe('SystemProxyResolver Integration', () => {
http_proxy: 'http://system-proxy.usebruno.com:8080',
https_proxy: 'https://system-proxy.usebruno.com:8443',
no_proxy: 'localhost',
pac_url: null,
source: 'macos-system'
});
});
@@ -263,6 +265,7 @@ describe('SystemProxyResolver Integration', () => {
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: null,
source: 'macos-system'
});
});

View File

@@ -95,6 +95,7 @@ export async function getSystemProxy(): Promise<ProxyConfiguration> {
http_proxy: proxyEnvironmentVariables?.http_proxy || systemProxyEnvironmentVariables?.http_proxy,
https_proxy: proxyEnvironmentVariables?.https_proxy || systemProxyEnvironmentVariables?.https_proxy,
no_proxy: proxyEnvironmentVariables?.no_proxy || systemProxyEnvironmentVariables?.no_proxy,
pac_url: systemProxyEnvironmentVariables?.pac_url || null,
source: hasEnvironmentProxy ? `${systemProxyEnvironmentVariables?.source} + environment` : systemProxyEnvironmentVariables?.source
};
} catch (error) {

View File

@@ -2,6 +2,7 @@ export interface ProxyConfiguration {
http_proxy?: string | null;
https_proxy?: string | null;
no_proxy?: string | null;
pac_url?: string | null;
source: string;
};

View File

@@ -90,10 +90,36 @@ describe('LinuxProxyResolver', () => {
});
});
it('should handle non-manual proxy mode', async () => {
const modeOutput = '\'auto\'';
it('should detect PAC URL when gsettings is in auto mode', async () => {
mockExecFile
.mockResolvedValueOnce({ stdout: '\'auto\'', stderr: '' })
.mockResolvedValueOnce({ stdout: '\'http://wpad.usebruno.com/proxy.pac\'', stderr: '' });
mockExecFile.mockResolvedValueOnce({ stdout: modeOutput, stderr: '' });
const result = await detector.detect();
expect(result).toEqual({
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: 'http://wpad.usebruno.com/proxy.pac',
source: 'linux-system'
});
});
it('should fall through when gsettings auto mode has an empty autoconfig-url', async () => {
mockExecFile
.mockResolvedValueOnce({ stdout: '\'auto\'', stderr: '' })
.mockResolvedValueOnce({ stdout: '\'\'', stderr: '' });
mockExistsSync.mockReturnValue(false);
await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed');
});
it('should fall through when gsettings mode is none', async () => {
mockExecFile.mockResolvedValueOnce({ stdout: '\'none\'', stderr: '' });
mockExistsSync.mockReturnValue(false);
await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed');
});

View File

@@ -49,6 +49,23 @@ export class LinuxProxyResolver implements ProxyResolver {
private async getGSettingsProxy(execOpts: ExecFileOptions): Promise<ProxyConfiguration | null> {
try {
const mode = await safeExec('gsettings', ['get', 'org.gnome.system.proxy', 'mode'], execOpts);
// Handle PAC (auto) mode
if (mode === '\'auto\'') {
const autoConfigUrl = await safeExec('gsettings', ['get', 'org.gnome.system.proxy', 'autoconfig-url'], execOpts);
const cleanUrl = (autoConfigUrl || '').replace(/'/g, '').trim();
if (cleanUrl) {
return {
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: cleanUrl,
source: 'linux-system'
};
}
return null;
}
if (mode !== '\'manual\'') {
return null;
}
@@ -93,8 +110,22 @@ export class LinuxProxyResolver implements ProxyResolver {
// 3 = Automatic proxy detection
// 4 = Use system proxy configuration (environment variables)
if (proxyType === '2') {
const pacUrl = await safeExec('kreadconfig5', ['--group', 'Proxy Settings', '--key', 'Proxy Config Script'], execOpts);
const cleanPacUrl = (pacUrl || '').trim();
if (cleanPacUrl) {
return {
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: cleanPacUrl,
source: 'linux-system'
};
}
return null;
}
if (proxyType !== '1') {
// Only handle manual proxy configuration for now
return null;
}

View File

@@ -45,6 +45,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://secure-proxy.usebruno.com:8443',
no_proxy: 'localhost,127.0.0.1,<local>',
pac_url: null,
source: 'macos-system'
});
});
@@ -65,6 +66,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: null,
source: 'macos-system'
});
});
@@ -102,6 +104,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: null,
no_proxy: null,
pac_url: null,
source: 'macos-system'
});
});
@@ -123,6 +126,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: null,
https_proxy: 'http://secure-proxy.usebruno.com:8443',
no_proxy: null,
pac_url: null,
source: 'macos-system'
});
});
@@ -148,6 +152,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://proxy.usebruno.com:8080',
no_proxy: null,
pac_url: null,
source: 'macos-system'
});
});
@@ -171,6 +176,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://proxy.usebruno.com:8080',
no_proxy: '<local>',
pac_url: null,
source: 'macos-system'
});
});
@@ -200,10 +206,72 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://proxy.usebruno.com:8080',
no_proxy: 'localhost,127.0.0.1,*.local,192.168.1.0/24,<local>',
pac_url: null,
source: 'macos-system'
});
});
it('should extract PAC URL when ProxyAutoConfigEnable is 1', async () => {
const scutilOutput = `<dictionary> {
HTTPEnable : 0
HTTPSEnable : 0
ProxyAutoConfigEnable : 1
ProxyAutoConfigURLString : http://wpad.usebruno.com/proxy.pac
}`;
mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' });
const result = await detector.detect();
expect(result).toEqual({
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: 'http://wpad.usebruno.com/proxy.pac',
source: 'macos-system'
});
});
it('should return PAC URL alongside manual HTTP/HTTPS proxies when both are configured', async () => {
const scutilOutput = `<dictionary> {
HTTPEnable : 1
HTTPPort : 8080
HTTPProxy : proxy.usebruno.com
HTTPSEnable : 1
HTTPSPort : 8443
HTTPSProxy : secure-proxy.usebruno.com
ProxyAutoConfigEnable : 1
ProxyAutoConfigURLString : file:///etc/proxy.pac
}`;
mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' });
const result = await detector.detect();
expect(result).toEqual({
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://secure-proxy.usebruno.com:8443',
no_proxy: null,
pac_url: 'file:///etc/proxy.pac',
source: 'macos-system'
});
});
it('should not return PAC URL when ProxyAutoConfigEnable is 0', async () => {
const scutilOutput = `<dictionary> {
HTTPEnable : 0
HTTPSEnable : 0
ProxyAutoConfigEnable : 0
ProxyAutoConfigURLString : http://wpad.usebruno.com/proxy.pac
}`;
mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' });
const result = await detector.detect();
expect(result.pac_url).toBeNull();
});
it('should handle malformed scutil output gracefully', async () => {
const scutilOutput = `<dictionary> {
HTTPEnable : 1
@@ -222,6 +290,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: null,
no_proxy: null,
pac_url: null,
source: 'macos-system'
});
});

View File

@@ -82,6 +82,12 @@ export class MacOSProxyResolver implements ProxyResolver {
let http_proxy: string | null = null;
let https_proxy: string | null = null;
let no_proxy: string | null = null;
let pac_url: string | null = null;
// Check PAC (Proxy Auto-Configuration)
if (config.ProxyAutoConfigEnable === 1 && config.ProxyAutoConfigURLString) {
pac_url = config.ProxyAutoConfigURLString;
}
// Check HTTP proxy
if (config.HTTPEnable === 1 && config.HTTPProxy) {
@@ -109,6 +115,7 @@ export class MacOSProxyResolver implements ProxyResolver {
http_proxy,
https_proxy,
no_proxy: normalizeNoProxy(no_proxy),
pac_url,
source: 'macos-system'
};
}

View File

@@ -83,6 +83,71 @@ Current WinHTTP proxy settings:
});
});
describe('AutoConfigURL (PAC) Detection', () => {
it('should return pac_url when only AutoConfigURL is set', async () => {
const regOutput = `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x0
AutoConfigURL REG_SZ http://wpad.usebruno.com/proxy.pac
`;
mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' });
const result = await detector.detect();
expect(result).toEqual({
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: 'http://wpad.usebruno.com/proxy.pac',
source: 'windows-system'
});
});
it('should return both manual proxy and pac_url when both are configured', async () => {
const regOutput = `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x1
ProxyServer REG_SZ proxy.usebruno.com:8080
ProxyOverride REG_SZ localhost;127.0.0.1
AutoConfigURL REG_SZ http://wpad.usebruno.com/proxy.pac
`;
mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' });
const result = await detector.detect();
expect(result).toEqual({
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://proxy.usebruno.com:8080',
no_proxy: 'localhost,127.0.0.1',
pac_url: 'http://wpad.usebruno.com/proxy.pac',
source: 'windows-system'
});
});
it('should return pac_url with null proxies when ProxyEnable=0 but AutoConfigURL is set', async () => {
const regOutput = `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x0
ProxyServer REG_SZ proxy.usebruno.com:8080
AutoConfigURL REG_SZ http://wpad.usebruno.com/proxy.pac
`;
mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' });
const result = await detector.detect();
expect(result).toEqual({
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: 'http://wpad.usebruno.com/proxy.pac',
source: 'windows-system'
});
});
});
describe('WinHTTP Detection', () => {
it('should handle direct access configuration', async () => {
mockExecFile

View File

@@ -46,6 +46,7 @@ export class WindowsProxyResolver implements ProxyResolver {
let proxyEnabled = false;
let proxyServer: string | null = null;
let proxyOverride: string | null = null;
let autoConfigURL: string | null = null;
for (const line of lines) {
const trimmedLine = line.trim();
@@ -68,6 +69,19 @@ export class WindowsProxyResolver implements ProxyResolver {
const match = trimmedLine.match(/ProxyOverride\s+REG_SZ\s+(.+)/);
if (match) proxyOverride = match[1].trim();
}
if (trimmedLine.includes('AutoConfigURL') && trimmedLine.includes('REG_SZ')) {
const match = trimmedLine.match(/AutoConfigURL\s+REG_SZ\s+(.+)/);
if (match) autoConfigURL = match[1].trim();
}
}
// PAC URL takes precedence — return it even without a manual proxy
if (autoConfigURL) {
const config = proxyEnabled && proxyServer
? this.parseProxyString(proxyServer, proxyOverride)
: { http_proxy: null, https_proxy: null, no_proxy: null, source: 'windows-system' };
return { ...config, pac_url: autoConfigURL };
}
if (proxyEnabled && proxyServer) {

View File

@@ -49,6 +49,7 @@ type SystemProxyConfig = {
http_proxy?: string;
https_proxy?: string;
no_proxy?: string;
pac_url?: string | null;
};
type ClientCertificate = {
@@ -215,7 +216,7 @@ const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthori
* `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> {
export class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
private constructorOpts: any;
constructor(proxy: string, opts: any) {
@@ -349,8 +350,8 @@ const getCertsAndProxyConfig = ({
proxyConfig = { pac: get(appLevelProxyConfig, 'pac.source') };
proxyMode = 'pac';
} else if (globalProxySource === 'inherit') {
const { http_proxy, https_proxy } = systemProxyConfig || {};
if (http_proxy?.length || https_proxy?.length) {
const { http_proxy, https_proxy, pac_url } = systemProxyConfig || {};
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
proxyMode = 'system';
}
} else {
@@ -362,8 +363,8 @@ const getCertsAndProxyConfig = ({
// else: app-level proxy is disabled, proxyMode stays 'off'
} else {
// No app-level proxy config (e.g. CLI), fall through to system proxy
const { http_proxy, https_proxy } = systemProxyConfig || {};
if (http_proxy?.length || https_proxy?.length) {
const { http_proxy, https_proxy, pac_url } = systemProxyConfig || {};
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
proxyMode = 'system';
}
}
@@ -382,6 +383,79 @@ function extractHostname(url: string | undefined): string | null {
}
}
type ResolveAgentsFromPacParams = {
pacSource: string;
requestUrl: string;
tlsOptions: TlsOptions;
httpsAgentRequestFields?: HttpsAgentRequestFields;
requestProtocol?: 'http' | 'https' | 'both';
timeline?: TimelineEntry[] | null;
disableCache: boolean;
hostname: string | null;
};
type ResolveAgentsFromPacResult = {
directives: string[] | null;
httpAgent?: HttpAgent;
httpsAgent?: HttpsAgent | HttpsProxyAgent<any> | SocksProxyAgent;
};
/**
* Resolves a PAC URL and creates proxy agents from the first directive.
* `requestProtocol` controls which agent(s) get created:
* - 'http' or 'https': create only the matching agent (optimization for known request type)
* - 'both' (default): create both, caller picks
*/
export async function resolveAgentsFromPac({
pacSource,
requestUrl,
tlsOptions,
httpsAgentRequestFields,
requestProtocol = 'both',
timeline,
disableCache,
hostname
}: ResolveAgentsFromPacParams): Promise<ResolveAgentsFromPacResult> {
const pacResolverFields = httpsAgentRequestFields || {
ca: tlsOptions.ca,
rejectUnauthorized: tlsOptions.rejectUnauthorized,
minVersion: tlsOptions.minVersion
};
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields: pacResolverFields });
const directives = await resolver.resolve(requestUrl);
if (!directives || !directives.length) {
return { directives: null };
}
const wantHttp = requestProtocol === 'http' || requestProtocol === 'both';
const wantHttps = requestProtocol === 'https' || requestProtocol === 'both';
const first = directives[0];
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}`;
const result: ResolveAgentsFromPacResult = { directives };
if (wantHttp) result.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
if (wantHttps) result.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
return result;
}
if (/^SOCKS/i.test(first)) {
const hostPort = first.split(/\s+/)[1];
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
const proxyUri = `${proto}://${hostPort}`;
const result: ResolveAgentsFromPacResult = { directives };
if (wantHttp) result.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
if (wantHttps) result.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
return result;
}
return { directives };
}
async function createAgents({
requestUrl,
proxyMode,
@@ -459,32 +533,9 @@ async function createAgents({
const pacSource = get(proxyConfig, 'pac.source');
if (pacSource && requestUrl) {
try {
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields: { ca: tlsOptions.ca, rejectUnauthorized: tlsOptions.rejectUnauthorized, minVersion: tlsOptions.minVersion } });
const directives = await resolver.resolve(requestUrl);
if (directives && directives.length) {
const first = directives[0];
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}`;
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
}
} 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}`;
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
}
}
}
const result = await resolveAgentsFromPac({ pacSource, requestUrl, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, timeline, disableCache, hostname });
if (result.httpAgent) httpAgent = result.httpAgent;
if (result.httpsAgent) httpsAgent = result.httpsAgent;
} catch {
// PAC resolution failed — fall through to direct connection
}
@@ -493,25 +544,37 @@ async function createAgents({
const http_proxy = get(systemProxyConfig, 'http_proxy');
const https_proxy = get(systemProxyConfig, 'https_proxy');
const no_proxy = get(systemProxyConfig, 'no_proxy');
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
if (shouldUseSystemProxy) {
const pac_url = get(systemProxyConfig, 'pac_url');
// If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
if (pac_url && requestUrl) {
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 };
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');
const result = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, timeline, disableCache, hostname });
if (result.httpAgent) httpAgent = result.httpAgent;
if (result.httpsAgent) httpsAgent = result.httpsAgent;
} catch {
}
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
const shouldUseSystemProxy = shouldUseProxy(requestUrl, 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 };
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 && isHttpsRequest) {
new URL(https_proxy);
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');
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
}
}
@@ -572,4 +635,4 @@ const getHttpHttpsAgents = async ({
export { getHttpHttpsAgents };
export type { GetHttpHttpsAgentsParams };
export type { GetHttpHttpsAgentsParams, ResolveAgentsFromPacParams, ResolveAgentsFromPacResult };

View File

@@ -3,20 +3,28 @@
"name": "bruno-testbench",
"type": "collection",
"proxy": {
"enabled": false,
"protocol": "http",
"hostname": "{{proxyHostname}}",
"port": 4000,
"auth": {
"enabled": false,
"username": "anoop",
"password": "password"
},
"bypassProxy": ""
"inherit": true,
"config": {
"protocol": "http",
"hostname": "{{proxyHostname}}",
"port": 4000,
"auth": {
"username": "anoop",
"password": "password",
"disabled": true
},
"bypassProxy": ""
}
},
"scripts": {
"moduleWhitelist": ["crypto", "buffer", "form-data"],
"additionalContextRoots": ["../additional-context-root-lib"]
"moduleWhitelist": [
"crypto",
"buffer",
"form-data"
],
"additionalContextRoots": [
"../additional-context-root-lib"
]
},
"clientCertificates": {
"enabled": true,