diff --git a/.github/actions/tests/run-e2e-tests/action.yml b/.github/actions/tests/run-e2e-tests/action.yml index fd9c9e109..c36509752 100644 --- a/.github/actions/tests/run-e2e-tests/action.yml +++ b/.github/actions/tests/run-e2e-tests/action.yml @@ -18,7 +18,7 @@ runs: - name: Run Playwright Tests (Ubuntu) if: inputs.os == 'ubuntu' shell: bash - run: xvfb-run npm run test:e2e + run: xvfb-run dbus-run-session -- npm run test:e2e - name: Run Playwright Tests if: inputs.os != 'ubuntu' diff --git a/.github/workflows/tests-linux.yml b/.github/workflows/tests-linux.yml index b7a0ff3e4..de4c0b02e 100644 --- a/.github/workflows/tests-linux.yml +++ b/.github/workflows/tests-linux.yml @@ -59,7 +59,8 @@ jobs: sudo apt-get update sudo apt-get --no-install-recommends install -y \ libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \ - xvfb + xvfb \ + gsettings-desktop-schemas dbus-x11 - name: Setup Node Dependencies uses: ./.github/actions/common/setup-node-deps diff --git a/package.json b/package.json index fd9089b97..f33eb7177 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "watch:common": "npm run watch --workspace=packages/bruno-common", "watch:requests": "npm run watch --workspace=packages/bruno-requests", "test:codegen": "node playwright/codegen.ts", - "test:e2e": "playwright test --project=default", + "test:e2e": "playwright test --project=default --project=system-pac", "test:e2e:ssl": "playwright test --project=ssl", "test:e2e:auth": "playwright test --project=auth", "test:benchmark": "playwright test --config=playwright.benchmark.config.ts", diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js index 6c6a39e88..4d937c899 100644 --- a/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js @@ -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 = () => {
{no_proxy || '-'}
+
+ +
{pac_url || '-'}
+
{ + 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, diff --git a/packages/bruno-cli/src/utils/proxy-util.js b/packages/bruno-cli/src/utils/proxy-util.js index 106602199..df14c4646 100644 --- a/packages/bruno-cli/src/utils/proxy-util.js +++ b/packages/bruno-cli/src/utils/proxy-util.js @@ -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) {} diff --git a/packages/bruno-electron/src/ipc/network/cert-utils.js b/packages/bruno-electron/src/ipc/network/cert-utils.js index a807ad347..e17cba331 100644 --- a/packages/bruno-electron/src/ipc/network/cert-utils.js +++ b/packages/bruno-electron/src/ipc/network/cert-utils.js @@ -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; diff --git a/packages/bruno-electron/src/store/system-proxy.js b/packages/bruno-electron/src/store/system-proxy.js index b471af951..3a40daa59 100644 --- a/packages/bruno-electron/src/store/system-proxy.js +++ b/packages/bruno-electron/src/store/system-proxy.js @@ -12,6 +12,7 @@ const loadSystemProxy = async () => { http_proxy: null, https_proxy: null, no_proxy: null, + pac_url: null, source: 'error' }; } diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js index 8398ba311..9c2eb7873 100644 --- a/packages/bruno-electron/src/utils/proxy-util.js +++ b/packages/bruno-electron/src/utils/proxy-util.js @@ -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)' }); } diff --git a/packages/bruno-electron/test/proxy-util.test.js b/packages/bruno-electron/test/proxy-util.test.js index 9dc9fbfff..55b869cde 100644 --- a/packages/bruno-electron/test/proxy-util.test.js +++ b/packages/bruno-electron/test/proxy-util.test.js @@ -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() })); diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index 6520c4e72..a71cc7481 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -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'; diff --git a/packages/bruno-requests/src/network/system-proxy/index.spec.js b/packages/bruno-requests/src/network/system-proxy/index.spec.js index 2438d566b..893dab218 100644 --- a/packages/bruno-requests/src/network/system-proxy/index.spec.js +++ b/packages/bruno-requests/src/network/system-proxy/index.spec.js @@ -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' }); }); diff --git a/packages/bruno-requests/src/network/system-proxy/index.ts b/packages/bruno-requests/src/network/system-proxy/index.ts index f56117a8c..56fa26686 100644 --- a/packages/bruno-requests/src/network/system-proxy/index.ts +++ b/packages/bruno-requests/src/network/system-proxy/index.ts @@ -95,6 +95,7 @@ export async function getSystemProxy(): Promise { 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) { diff --git a/packages/bruno-requests/src/network/system-proxy/types.ts b/packages/bruno-requests/src/network/system-proxy/types.ts index a438691ae..dfb2a95d4 100644 --- a/packages/bruno-requests/src/network/system-proxy/types.ts +++ b/packages/bruno-requests/src/network/system-proxy/types.ts @@ -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; }; diff --git a/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts index be931bb3d..03efb1ab9 100644 --- a/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts +++ b/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts @@ -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'); }); diff --git a/packages/bruno-requests/src/network/system-proxy/utils/linux.ts b/packages/bruno-requests/src/network/system-proxy/utils/linux.ts index 0c8cf45b6..16e9b8d67 100644 --- a/packages/bruno-requests/src/network/system-proxy/utils/linux.ts +++ b/packages/bruno-requests/src/network/system-proxy/utils/linux.ts @@ -49,6 +49,23 @@ export class LinuxProxyResolver implements ProxyResolver { private async getGSettingsProxy(execOpts: ExecFileOptions): Promise { 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; } diff --git a/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts index 89b31839d..24758dbf0 100644 --- a/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts +++ b/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts @@ -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,', + 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: '', + 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,', + pac_url: null, source: 'macos-system' }); }); + it('should extract PAC URL when ProxyAutoConfigEnable is 1', async () => { + const scutilOutput = ` { + 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 = ` { + 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 = ` { + 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 = ` { 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' }); }); diff --git a/packages/bruno-requests/src/network/system-proxy/utils/macos.ts b/packages/bruno-requests/src/network/system-proxy/utils/macos.ts index 8bbee0a58..fe948469c 100644 --- a/packages/bruno-requests/src/network/system-proxy/utils/macos.ts +++ b/packages/bruno-requests/src/network/system-proxy/utils/macos.ts @@ -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' }; } diff --git a/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts index c35b9b1d3..7dbc28791 100644 --- a/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts +++ b/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts @@ -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 diff --git a/packages/bruno-requests/src/network/system-proxy/utils/windows.ts b/packages/bruno-requests/src/network/system-proxy/utils/windows.ts index 9962ac715..1bf4f7f9d 100644 --- a/packages/bruno-requests/src/network/system-proxy/utils/windows.ts +++ b/packages/bruno-requests/src/network/system-proxy/utils/windows.ts @@ -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) { diff --git a/packages/bruno-requests/src/utils/http-https-agents.ts b/packages/bruno-requests/src/utils/http-https-agents.ts index 21153aad3..54037742d 100644 --- a/packages/bruno-requests/src/utils/http-https-agents.ts +++ b/packages/bruno-requests/src/utils/http-https-agents.ts @@ -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 { +export class PatchedHttpsProxyAgent extends HttpsProxyAgent { 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 | 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 { + 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 }; diff --git a/packages/bruno-tests/collection/bruno.json b/packages/bruno-tests/collection/bruno.json index 09a346b4a..03d9f650c 100644 --- a/packages/bruno-tests/collection/bruno.json +++ b/packages/bruno-tests/collection/bruno.json @@ -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, diff --git a/playwright.config.ts b/playwright.config.ts index eb758f08d..02bbbd60f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -24,7 +24,8 @@ export default defineConfig({ testIgnore: [ 'ssl/**', // custom CA certificate tests require separate server setup and certificate generation 'auth/**', // auth tests have their own project - 'benchmarks/**' + 'benchmarks/**', + 'proxy/system-pac/**' // shares ports with proxy/pac — runs in its own project after default ] }, { @@ -34,6 +35,12 @@ export default defineConfig({ { name: 'ssl', testDir: './tests/ssl' + }, + { + // system-pac and pac specs share the same PAC/proxy/target ports. + name: 'system-pac', + testDir: './tests/proxy/system-pac', + dependencies: ['default'] } ], diff --git a/tests/proxy/system-pac/fixtures/collection/bruno.json b/tests/proxy/system-pac/fixtures/collection/bruno.json new file mode 100644 index 000000000..7a6c2c8e5 --- /dev/null +++ b/tests/proxy/system-pac/fixtures/collection/bruno.json @@ -0,0 +1,6 @@ +{ + "version": "1", + "name": "system-pac-proxy-test", + "type": "collection", + "ignore": [] +} diff --git a/tests/proxy/system-pac/fixtures/collection/direct.bru b/tests/proxy/system-pac/fixtures/collection/direct.bru new file mode 100644 index 000000000..bebfc86b1 --- /dev/null +++ b/tests/proxy/system-pac/fixtures/collection/direct.bru @@ -0,0 +1,21 @@ +meta { + name: direct + type: http + seq: 2 +} + +get { + url: http://localhost:19000/direct + body: none + auth: none +} + +assert { + res.status: eq 200 +} + +tests { + test("request bypassed proxy (system PAC returned DIRECT)", function() { + expect(res.headers['x-proxied']).to.be.undefined; + }); +} diff --git a/tests/proxy/system-pac/fixtures/collection/proxied.bru b/tests/proxy/system-pac/fixtures/collection/proxied.bru new file mode 100644 index 000000000..b5bd679f3 --- /dev/null +++ b/tests/proxy/system-pac/fixtures/collection/proxied.bru @@ -0,0 +1,21 @@ +meta { + name: proxied + type: http + seq: 1 +} + +get { + url: http://localhost:19000/proxied + body: none + auth: none +} + +assert { + res.status: eq 200 +} + +tests { + test("request was routed through system PAC proxy", function() { + expect(res.headers['x-proxied']).to.equal('test-proxy'); + }); +} diff --git a/tests/proxy/system-pac/init-user-data/preferences.json b/tests/proxy/system-pac/init-user-data/preferences.json new file mode 100644 index 000000000..3b7577d84 --- /dev/null +++ b/tests/proxy/system-pac/init-user-data/preferences.json @@ -0,0 +1,14 @@ +{ + "maximized": false, + "lastOpenedCollections": ["{{projectRoot}}/tests/proxy/system-pac/fixtures/collection"], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + }, + "proxy": { + "source": "inherit", + "config": {} + } + } +} diff --git a/tests/proxy/system-pac/system-pac-proxy.spec.ts b/tests/proxy/system-pac/system-pac-proxy.spec.ts new file mode 100644 index 000000000..4548f9cb8 --- /dev/null +++ b/tests/proxy/system-pac/system-pac-proxy.spec.ts @@ -0,0 +1,103 @@ +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { pathToFileURL } from 'url'; +import { test } from '../../../playwright'; +import { setSandboxMode, runCollection, validateRunnerResults } from '../../utils/page'; +import { startServers, stopServers, PAC_PORT, type TestServers } from '../pac/server'; + +// GNOME's system-wide proxy schema — provided by gsettings-desktop-schemas. +// Writes need an active dbus session (see CI workflow's dbus-run-session wrapper). +const GNOME_PROXY_SCHEMA = 'org.gnome.system.proxy'; + +function enableSystemPac(pacUrl: string) { + // Set URL first, then flip mode — otherwise auto mode briefly has no URL + execFileSync('gsettings', ['set', GNOME_PROXY_SCHEMA, 'autoconfig-url', pacUrl]); + execFileSync('gsettings', ['set', GNOME_PROXY_SCHEMA, 'mode', 'auto']); +} + +function disableSystemPac() { + execFileSync('gsettings', ['reset', GNOME_PROXY_SCHEMA, 'mode']); + execFileSync('gsettings', ['reset', GNOME_PROXY_SCHEMA, 'autoconfig-url']); +} + +// Detects schema availability so we can skip cleanly on minimal images +// (e.g. containers without gsettings-desktop-schemas installed). +function gnomeProxySchemaAvailable(): boolean { + try { + execFileSync('gsettings', ['get', GNOME_PROXY_SCHEMA, 'mode'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +test.describe('System Proxy with PAC', () => { + test.skip( + process.platform !== 'linux', + 'Linux-only: relies on gsettings to set OS-level PAC' + ); + + test.skip( + process.platform === 'linux' && !gnomeProxySchemaAvailable(), + 'Linux: skipping because the org.gnome.system.proxy GSettings schema is not available on this runner' + ); + + let servers: TestServers; + + test.beforeAll(async () => { + servers = await startServers(); + }); + + test.afterAll(async () => { + // Revert OS proxy settings even if a test failed, so the runner is left clean. + try { + disableSystemPac(); + } finally { + if (servers) { + await stopServers(servers); + } + } + }); + + // Covers the common corporate setup: PAC hosted at an HTTP URL (e.g. WPAD). + test('resolves OS-level PAC URL in system proxy mode (HTTP PAC)', async ({ launchElectronApp }) => { + const pacUrl = `http://localhost:${PAC_PORT}/test.pac`; + enableSystemPac(pacUrl); + + const initUserDataPath = path.join(__dirname, 'init-user-data'); + const app = await launchElectronApp({ initUserDataPath }); + + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await setSandboxMode(page, 'system-pac-proxy-test', 'developer'); + await runCollection(page, 'system-pac-proxy-test'); + await validateRunnerResults(page, { + totalRequests: 2, + passed: 2, + failed: 0, + skipped: 0 + }); + }); + + // Covers the local-file PAC case (user-selected .pac file on disk). + test('resolves OS-level PAC URL in system proxy mode (file:// PAC)', async ({ launchElectronApp }) => { + const pacUrl = pathToFileURL(path.join(__dirname, '..', 'pac', 'fixtures', 'pac-files', 'test.pac')).href; + enableSystemPac(pacUrl); + + const initUserDataPath = path.join(__dirname, 'init-user-data'); + const app = await launchElectronApp({ initUserDataPath }); + + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await setSandboxMode(page, 'system-pac-proxy-test', 'developer'); + await runCollection(page, 'system-pac-proxy-test'); + await validateRunnerResults(page, { + totalRequests: 2, + passed: 2, + failed: 0, + skipped: 0 + }); + }); +});