diff --git a/package-lock.json b/package-lock.json index 8eab4cf11..fb6d6b005 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17654,6 +17654,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/degenerator/node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -18560,7 +18586,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", @@ -18582,7 +18607,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -18965,7 +18989,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -24678,6 +24701,15 @@ "node": ">= 10" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/new-github-issue-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/new-github-issue-url/-/new-github-issue-url-0.2.1.tgz", @@ -25190,6 +25222,19 @@ "node": ">=6" } }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -35969,6 +36014,8 @@ "http-proxy-agent": "~7.0.2", "https-proxy-agent": "~7.0.6", "is-ip": "^5.0.1", + "pac-resolver": "^7.0.1", + "quickjs-emscripten": "^0.32.0", "shell-env": "^4.0.1", "socks-proxy-agent": "~8.0.5", "system-ca": "^2.0.1", @@ -36010,6 +36057,48 @@ "npm": ">=9.0.0" } }, + "packages/bruno-requests/node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "packages/bruno-requests/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -36033,6 +36122,31 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "packages/bruno-requests/node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/bruno-requests/node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "packages/bruno-requests/node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js index d9a0539e0..704c7b257 100644 --- a/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js @@ -5,7 +5,7 @@ const StyledWrapper = styled.div` flex-direction: column; gap: 1rem; width: 100%; - + .settings-label { width: 100px; } @@ -26,6 +26,57 @@ const StyledWrapper = styled.div` } } + .pac-mode-toggle { + display: inline-flex; + flex-shrink: 0; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.base}; + overflow: hidden; + margin-right: 12px; + } + + .pac-mode-btn { + height: 34px; + padding: 0.1rem 0.6rem; + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + cursor: pointer; + transition: background 0.12s, color 0.12s; + white-space: nowrap; + + &.active { + background: ${(props) => props.theme.button.secondary.bg}; + color: ${(props) => props.theme.button.secondary.color}; + } + + &:hover:not(.active) { + color: ${(props) => props.theme.text}; + } + } + + .pac-source-input { + width: 265px; + } + + .pac-file-btn { + text-align: left; + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .pac-hint { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + margin-top: 4px; + padding-left: 100px; + } + .system-proxy-settings { label { color: ${(props) => props.theme.colors.text.yellow}; diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js index 5e23e42ab..0ac632e19 100644 --- a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js @@ -17,7 +17,22 @@ const ProxySettings = ({ close }) => { const proxySchema = Yup.object({ disabled: Yup.boolean().optional(), - inherit: Yup.boolean().required(), + source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(), + pac: Yup.object({ + source: Yup.string() + .optional() + .test('pac-url', 'Specify a valid PAC URL', (value) => { + if (!value) return true; + try { + const u = new URL(value); + return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:'; + } catch { + return false; + } + }) + .max(2048) + .nullable() + }).optional(), config: Yup.object({ protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']), hostname: Yup.string().max(1024), @@ -39,7 +54,10 @@ const ProxySettings = ({ close }) => { const formik = useFormik({ initialValues: { disabled: preferences.proxy.disabled || false, - inherit: preferences.proxy.inherit || false, + source: preferences.proxy.source || 'manual', + pac: { + source: preferences.proxy.pac?.source || '' + }, config: { protocol: preferences.proxy.config?.protocol || 'http', hostname: preferences.proxy.config?.hostname || '', @@ -86,15 +104,26 @@ const ProxySettings = ({ close }) => { ); const [passwordVisible, setPasswordVisible] = useState(false); + const [proxyMode, setProxyMode] = useState(() => { + if (preferences.proxy.disabled) return 'off'; + if (preferences.proxy.source === 'pac') return 'pac'; + if (preferences.proxy.source === 'inherit') return 'inherit'; + return 'manual'; + }); + const [pacInputMode, setPacInputMode] = useState(() => + preferences.proxy.pac?.source?.startsWith('file://') ? 'file' : 'url' + ); useEffect(() => { if (formik.dirty && formik.isValid) { + // Don't auto-save PAC mode until a URL or file is actually selected. + if (proxyMode === 'pac' && !formik.values.pac.source) return; debouncedSave(formik.values); } return () => { debouncedSave.flush(); }; - }, [formik.values, formik.dirty, formik.isValid, debouncedSave]); + }, [formik.values, formik.dirty, formik.isValid, debouncedSave, proxyMode]); return ( @@ -110,10 +139,10 @@ const ProxySettings = ({ close }) => { type="radio" name="mode" value="off" - checked={formik.values.disabled === true} + checked={proxyMode === 'off'} onChange={(e) => { + setProxyMode('off'); formik.setFieldValue('disabled', true); - formik.setFieldValue('inherit', false); }} className="mr-1 cursor-pointer" /> @@ -123,11 +152,12 @@ const ProxySettings = ({ close }) => { { + setProxyMode('manual'); formik.setFieldValue('disabled', false); - formik.setFieldValue('inherit', false); + formik.setFieldValue('source', 'manual'); }} className="mr-1 cursor-pointer" /> @@ -137,24 +167,40 @@ const ProxySettings = ({ close }) => { { + setProxyMode('inherit'); formik.setFieldValue('disabled', false); - formik.setFieldValue('inherit', true); + formik.setFieldValue('source', 'inherit'); }} className="mr-1 cursor-pointer" /> System Proxy + - {formik.values.disabled === false && formik.values.inherit === true ? ( + {proxyMode === 'inherit' ? (
) : null} - {formik.values.disabled === false && formik.values.inherit === false ? ( + {proxyMode === 'manual' ? ( <>
) : null} + {proxyMode === 'pac' ? ( + <> +
+
+ +
+ + +
+ {pacInputMode === 'url' ? ( + + ) : ( + + )} + {formik.touched.pac?.source && formik.errors.pac?.source ? ( +
{formik.errors.pac.source}
+ ) : null} +
+

+ {pacInputMode === 'url' + ? 'Enter the URL to your PAC file' + : 'Supports .pac files for automatic proxy configuration'} +

+
+ + ) : null}
); diff --git a/packages/bruno-common/package.json b/packages/bruno-common/package.json index 765295961..f3b684024 100644 --- a/packages/bruno-common/package.json +++ b/packages/bruno-common/package.json @@ -36,6 +36,7 @@ "watch": "rollup -c -w", "prepack": "npm run test && npm run build" }, + "dependencies": {}, "devDependencies": { "@babel/preset-env": "^7.26.9", "@babel/preset-typescript": "^7.27.0", @@ -43,6 +44,7 @@ "@jest/globals": "^29.7.0", "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.1.2", "@types/jest": "^29.5.14", "babel-jest": "^29.7.0", @@ -52,7 +54,6 @@ "rollup": "3.30.0", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", - "@rollup/plugin-terser": "^1.0.0", "typescript": "^5.8.3" }, "overrides": { diff --git a/packages/bruno-common/rollup.config.js b/packages/bruno-common/rollup.config.js index 84c451385..49fa265ce 100644 --- a/packages/bruno-common/rollup.config.js +++ b/packages/bruno-common/rollup.config.js @@ -7,10 +7,11 @@ const peerDepsExternal = require('rollup-plugin-peer-deps-external'); const packageJson = require('./package.json'); -function createBuildConfig({ inputDir, input, cjsOutput, esmOutput }) { +function createBuildConfig({ inputDir, input, cjsOutput, esmOutput, dtsOutput, external = [] }) { return [ { input, + external, output: [ { file: cjsOutput, @@ -36,30 +37,38 @@ function createBuildConfig({ inputDir, input, cjsOutput, esmOutput }) { treeshake: { moduleSideEffects: false } + }, + { + input, + external, + output: { file: dtsOutput, format: 'es' }, + plugins: [dts.default({ tsconfig: './tsconfig.json' })] } ]; } -// todo: configure declarations module.exports = [ // Main package build ...createBuildConfig({ inputDir: 'src/**/*', input: 'src/index.ts', cjsOutput: packageJson.main, - esmOutput: packageJson.module + esmOutput: packageJson.module, + dtsOutput: packageJson.types }), // reports/html ...createBuildConfig({ inputDir: 'src/runner/**/*', input: 'src/runner/index.ts', cjsOutput: 'dist/runner/cjs/index.js', - esmOutput: 'dist/runner/esm/index.js' + esmOutput: 'dist/runner/esm/index.js', + dtsOutput: 'dist/runner/index.d.ts' }), ...createBuildConfig({ inputDir: 'src/utils/**/*', input: 'src/utils/index.ts', cjsOutput: 'dist/utils/cjs/index.js', - esmOutput: 'dist/utils/esm/index.js' - }) + esmOutput: 'dist/utils/esm/index.js', + dtsOutput: 'dist/utils/index.d.ts' + }), ]; diff --git a/packages/bruno-electron/src/ipc/filesystem.js b/packages/bruno-electron/src/ipc/filesystem.js index 025d5b945..024b60f70 100644 --- a/packages/bruno-electron/src/ipc/filesystem.js +++ b/packages/bruno-electron/src/ipc/filesystem.js @@ -1,5 +1,6 @@ -const { ipcMain } = require('electron'); +const { ipcMain, dialog } = require('electron'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); const { browseDirectory, @@ -27,6 +28,15 @@ const registerFilesystemIpc = (mainWindow) => { } }); + ipcMain.handle('renderer:browse-pac-file', async () => { + const { filePaths } = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: [{ name: 'PAC Files', extensions: ['pac', 'js'] }] + }); + if (!filePaths || filePaths.length === 0) return null; + return pathToFileURL(filePaths[0]).href; + }); + ipcMain.handle('renderer:exists-sync', async (_, filePath) => { try { const normalizedPath = normalizeAndResolvePath(filePath); diff --git a/packages/bruno-electron/src/ipc/network/axios-instance.js b/packages/bruno-electron/src/ipc/network/axios-instance.js index afd22165b..a22475bc8 100644 --- a/packages/bruno-electron/src/ipc/network/axios-instance.js +++ b/packages/bruno-electron/src/ipc/network/axios-instance.js @@ -73,6 +73,7 @@ const checkConnection = (host, port) => */ function makeAxiosInstance({ proxyMode = 'off', + proxyModeReason = '', proxyConfig = {}, requestMaxRedirects = 5, httpsAgentRequestFields = {}, @@ -202,19 +203,17 @@ function makeAxiosInstance({ }; try { - // Now call setupProxyAgents and pass the timeline - setupProxyAgents({ + // Now call setupProxyAgents and pass the timeline (async - may perform PAC resolution) + await setupProxyAgents({ requestConfig: config, - proxyMode: proxyMode, // 'on', 'off', or 'system', depending on your settings - proxyConfig: proxyConfig, + proxyMode, + proxyModeReason, + proxyConfig, httpsAgentRequestFields: agentOptions, - interpolationOptions: interpolationOptions, // Provide your interpolation options + interpolationOptions, timeline }); } catch (err) { - if (err.timeline) { - timeline = err.timeline; - } timeline.push({ timestamp: new Date(), type: 'error', @@ -270,7 +269,7 @@ function makeAxiosInstance({ response.timeline = timeline; return response; }, - (error) => { + async (error) => { const config = error.config; const timeline = config?.metadata?.timeline || []; timeline?.push({ @@ -417,9 +416,10 @@ function makeAxiosInstance({ } try { - setupProxyAgents({ + await setupProxyAgents({ requestConfig, proxyMode, + proxyModeReason, proxyConfig, httpsAgentRequestFields, interpolationOptions, diff --git a/packages/bruno-electron/src/ipc/network/cert-utils.js b/packages/bruno-electron/src/ipc/network/cert-utils.js index 3fd45742c..a807ad347 100644 --- a/packages/bruno-electron/src/ipc/network/cert-utils.js +++ b/packages/bruno-electron/src/ipc/network/cert-utils.js @@ -121,6 +121,7 @@ const getCertsAndProxyConfig = async ({ */ let proxyMode = 'off'; let proxyConfig = {}; + let proxyModeReason = ''; const collectionProxyConfig = get(brunoConfig, 'proxy', {}); const collectionProxyDisabled = get(collectionProxyConfig, 'disabled', false); @@ -135,24 +136,32 @@ const getCertsAndProxyConfig = async ({ // Inherit from global preferences const globalProxy = preferencesUtil.getGlobalProxyConfig(); const globalDisabled = get(globalProxy, 'disabled', false); - const globalInherit = get(globalProxy, 'inherit', false); - const globalProxyConfigData = get(globalProxy, 'config', globalProxy); + const globalProxySource = get(globalProxy, 'source', 'manual'); + const globalProxyConfigData = get(globalProxy, 'config', {}); - if (!globalDisabled && !globalInherit) { - // Use global custom proxy - proxyConfig = globalProxyConfigData; - proxyMode = 'on'; - } else if (!globalDisabled && globalInherit) { - // Use system proxy (cached at app startup) - proxyMode = 'system'; - const systemProxyConfig = await getCachedSystemProxy(); - proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, source: 'cache-miss' }; + if (!globalDisabled) { + if (globalProxySource === 'pac') { + proxyMode = 'pac'; + proxyConfig = { + pac: globalProxy.pac ?? {} + }; + } else if (globalProxySource === 'inherit') { + proxyMode = 'system'; + const systemProxyConfig = await getCachedSystemProxy(); + proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, source: 'cache-miss' }; + } else { + // source === 'manual' + proxyConfig = globalProxyConfigData; + proxyMode = 'on'; + } + } else { + proxyModeReason = 'App-level proxy is disabled'; } - // else: global proxy is disabled, proxyMode stays 'off' + } else { + proxyModeReason = 'Collection-level proxy is disabled'; } - // else: collection proxy is disabled, proxyMode stays 'off' - return { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions }; + return { proxyMode, proxyModeReason, proxyConfig, httpsAgentRequestFields, interpolationOptions }; }; /** diff --git a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js index 05f084417..ac3f86e43 100644 --- a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js +++ b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js @@ -11,6 +11,7 @@ const prepareGrpcRequest = require('./prepare-grpc-request'); const { normalizeAndResolvePath } = require('../../utils/filesystem'); const { configureRequest } = require('./prepare-grpc-request'); const { shouldUseProxy } = require('../../utils/proxy-util'); +const { getPacResolver } = require('@usebruno/requests'); // Creating grpcClient at module level so it can be accessed from window-all-closed event let grpcClient; @@ -29,7 +30,37 @@ let grpcClient; * @param {Object} interpolationOptions - Variable interpolation options * @returns {{ proxyUrl: string | null }} */ -const resolveGrpcProxyConfig = (proxyMode, proxyConfig, requestUrl, interpolationOptions) => { +const resolveGrpcProxyConfig = async (proxyMode, proxyConfig, requestUrl, interpolationOptions) => { + if (proxyMode === 'pac') { + const pacSource = get(proxyConfig, 'pac.source'); + if (!pacSource || !requestUrl) return { proxyUrl: null }; + + try { + const resolver = await getPacResolver({ pacSource }); + const directives = await resolver.resolve(requestUrl); + if (!directives || !directives.length) return { proxyUrl: null }; + + for (const directive of directives) { + if (/^DIRECT$/i.test(directive)) return { proxyUrl: null }; + if (/^(PROXY|HTTP)\s+/i.test(directive)) { + const hostPort = directive.split(/\s+/)[1]; + return { proxyUrl: `http://${hostPort}` }; + } + if (/^HTTPS\s+/i.test(directive)) { + console.warn('gRPC proxy: PAC returned an HTTPS proxy directive which is not supported for gRPC connections. Skipping.'); + continue; + } + if (/^SOCKS/i.test(directive)) { + console.warn('gRPC proxy: PAC returned a SOCKS proxy directive which is not supported for gRPC connections. Skipping.'); + continue; + } + } + } catch (e) { + console.warn('gRPC proxy: PAC resolution failed:', e.message); + } + return { proxyUrl: null }; + } + if (proxyMode === 'on') { const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', '')); if (!shouldProxy) return { proxyUrl: null }; @@ -170,7 +201,7 @@ const registerGrpcEventHandlers = (window) => { const pfx = httpsAgentRequestFields.pfx; // Resolve proxy configuration for gRPC - const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions); + const grpcProxyConfig = await resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions); const requestSent = { type: 'request', @@ -330,7 +361,7 @@ const registerGrpcEventHandlers = (window) => { const pfx = httpsAgentRequestFields.pfx; // Resolve proxy configuration for gRPC - const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions); + const grpcProxyConfig = await resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions); // Send OAuth credentials update if available if (preparedRequest?.oauth2Credentials) { diff --git a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.spec.js b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.spec.js index e548e4c39..8a459b41f 100644 --- a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.spec.js +++ b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.spec.js @@ -8,139 +8,139 @@ const emptyInterpolationOptions = {}; describe('resolveGrpcProxyConfig', () => { describe('proxyMode "off"', () => { - it('should return null proxyUrl', () => { - expect(resolveGrpcProxyConfig('off', {}, 'grpc://localhost:50051', emptyInterpolationOptions)) - .toEqual({ proxyUrl: null }); + it('should return null proxyUrl', async () => { + await expect(resolveGrpcProxyConfig('off', {}, 'grpc://localhost:50051', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: null }); }); }); describe('proxyMode "on"', () => { - it('should return proxy URL without auth', () => { + it('should return proxy URL without auth', async () => { const proxyConfig = { protocol: 'http', hostname: 'proxy.example.com', port: '8080', auth: { disabled: true } }; - expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: 'http://proxy.example.com:8080' }); + await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: 'http://proxy.example.com:8080' }); }); - it('should return proxy URL with auth when auth is enabled', () => { + it('should return proxy URL with auth when auth is enabled', async () => { const proxyConfig = { protocol: 'http', hostname: 'proxy.example.com', port: '8080', auth: { disabled: false, username: 'user', password: 'pass' } }; - expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: 'http://user:pass@proxy.example.com:8080' }); + await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: 'http://user:pass@proxy.example.com:8080' }); }); - it('should URL-encode special characters in credentials', () => { + it('should URL-encode special characters in credentials', async () => { const proxyConfig = { protocol: 'http', hostname: 'proxy.example.com', port: '8080', auth: { disabled: false, username: 'user@domain', password: 'p@ss:word' } }; - const result = resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions); + const result = await resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions); expect(result.proxyUrl).toBe('http://user%40domain:p%40ss%3Aword@proxy.example.com:8080'); }); - it('should reject SOCKS proxy protocols', () => { + it('should reject SOCKS proxy protocols', async () => { const proxyConfig = { protocol: 'socks5', hostname: 'proxy.example.com', port: '1080' }; - expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: null }); + await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: null }); }); - it('should reject HTTPS proxy protocol', () => { + it('should reject HTTPS proxy protocol', async () => { const proxyConfig = { protocol: 'https', hostname: 'proxy.example.com', port: '8080' }; - expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: null }); + await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: null }); }); - it('should return null when request URL is in bypassProxy list', () => { + it('should return null when request URL is in bypassProxy list', async () => { const proxyConfig = { protocol: 'http', hostname: 'proxy.example.com', port: '8080', bypassProxy: 'localhost,api.example.com' }; - expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: null }); + await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: null }); }); - it('should omit port when not provided', () => { + it('should omit port when not provided', async () => { const proxyConfig = { protocol: 'http', hostname: 'proxy.example.com', auth: { disabled: true } }; - expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: 'http://proxy.example.com' }); + await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: 'http://proxy.example.com' }); }); }); describe('proxyMode "system"', () => { - it('should use https_proxy when available', () => { + it('should use https_proxy when available', async () => { const proxyConfig = { https_proxy: 'http://system-proxy.example.com:3128', http_proxy: 'http://fallback-proxy.example.com:3128' }; - expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: 'http://system-proxy.example.com:3128' }); + await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: 'http://system-proxy.example.com:3128' }); }); - it('should fall back to http_proxy when https_proxy is not set', () => { + it('should fall back to http_proxy when https_proxy is not set', async () => { const proxyConfig = { http_proxy: 'http://fallback-proxy.example.com:3128' }; - expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: 'http://fallback-proxy.example.com:3128' }); + await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: 'http://fallback-proxy.example.com:3128' }); }); - it('should return null when no system proxy is configured', () => { - expect(resolveGrpcProxyConfig('system', {}, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: null }); + it('should return null when no system proxy is configured', async () => { + await expect(resolveGrpcProxyConfig('system', {}, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: null }); }); - it('should reject non-HTTP system proxy protocols', () => { + it('should reject non-HTTP system proxy protocols', async () => { const proxyConfig = { https_proxy: 'socks5://system-proxy.example.com:1080' }; - expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: null }); + await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: null }); }); - it('should return null when request URL matches no_proxy', () => { + it('should return null when request URL matches no_proxy', async () => { const proxyConfig = { https_proxy: 'http://system-proxy.example.com:3128', no_proxy: 'api.example.com' }; - expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: null }); + await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: null }); }); - it('should return null for invalid system proxy URL', () => { + it('should return null for invalid system proxy URL', async () => { const proxyConfig = { https_proxy: 'not-a-valid-url' }; - expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: null }); + await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: null }); }); - it('should return null when proxyConfig is null', () => { - expect(resolveGrpcProxyConfig('system', null, 'grpc://api.example.com:443', emptyInterpolationOptions)) - .toEqual({ proxyUrl: null }); + it('should return null when proxyConfig is null', async () => { + await expect(resolveGrpcProxyConfig('system', null, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .resolves.toEqual({ proxyUrl: null }); }); }); }); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 9a1dbb251..14475aa45 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -144,9 +144,10 @@ const configureRequest = async ( request.maxRedirects = 0; const { promptVariables = {} } = collection; - let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig; + let { proxyMode, proxyModeReason, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig; let axiosInstance = makeAxiosInstance({ proxyMode, + proxyModeReason, proxyConfig, requestMaxRedirects, httpsAgentRequestFields, diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index ad888fc1b..0f668874e 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -30,7 +30,8 @@ const defaultPreferences = { codeFontSize: 13 }, proxy: { - inherit: true, + source: 'inherit', + pac: { source: '' }, config: { protocol: 'http', hostname: '', @@ -93,7 +94,10 @@ const preferencesSchema = Yup.object().shape({ }), proxy: Yup.object({ disabled: Yup.boolean().optional(), - inherit: Yup.boolean().required(), + source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(), + pac: Yup.object({ + source: Yup.string().optional().max(2048).nullable() + }).optional(), config: Yup.object({ protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']), hostname: Yup.string().max(1024), @@ -150,7 +154,7 @@ class PreferencesStore { // New users (empty preferences) will get defaultPreferences.proxy via merge if (Object.keys(preferences).length > 0 && !preferences.proxy) { preferences.proxy = { - inherit: false, + source: 'manual', disabled: true, config: { protocol: 'http', @@ -173,7 +177,8 @@ class PreferencesStore { if (hasOldFormat) { let newProxy = { - inherit: true, + source: 'inherit', + pac: { source: '' }, config: { protocol: proxy.protocol || 'http', hostname: proxy.hostname || '', @@ -188,19 +193,17 @@ class PreferencesStore { // Handle old format 1: enabled (boolean) if (proxy.hasOwnProperty('enabled') && typeof proxy.enabled === 'boolean') { + newProxy.source = 'manual'; newProxy.disabled = !proxy.enabled; - newProxy.inherit = false; } else if (proxy.hasOwnProperty('mode')) { // Handle old format 2: mode ('off' | 'on' | 'system') if (proxy.mode === 'off') { + newProxy.source = 'manual'; newProxy.disabled = true; - newProxy.inherit = false; } else if (proxy.mode === 'on') { - newProxy.disabled = false; - newProxy.inherit = false; + newProxy.source = 'manual'; } else if (proxy.mode === 'system') { - newProxy.disabled = false; - newProxy.inherit = true; + newProxy.source = 'inherit'; } } @@ -208,7 +211,6 @@ class PreferencesStore { if (get(proxy, 'auth.enabled') === false) { newProxy.config.auth.disabled = true; } - // If auth.enabled is true or undefined, omit disabled (defaults to false) // Omit disabled: false at top level (optional field) if (newProxy.disabled === false) { @@ -220,6 +222,18 @@ class PreferencesStore { } preferences.proxy = newProxy; + this.store.set('preferences', preferences); + } + + // Migrate intermediate format: inherit boolean → source string + if (!hasOldFormat && proxy.hasOwnProperty('inherit')) { + if (proxy.inherit === true) { + preferences.proxy.source = 'inherit'; + } else if (!proxy.source) { + preferences.proxy.source = 'manual'; + } + delete preferences.proxy.inherit; + this.store.set('preferences', preferences); } } diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js index db7f3233f..8398ba311 100644 --- a/packages/bruno-electron/src/utils/proxy-util.js +++ b/packages/bruno-electron/src/utils/proxy-util.js @@ -8,6 +8,7 @@ const { HttpProxyAgent } = require('http-proxy-agent'); const { isEmpty, get, isUndefined, isNull } = require('lodash'); const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests'); const { preferencesUtil } = require('../store/preferences'); +const { getPacResolver } = require('@usebruno/requests'); const DEFAULT_PORTS = { ftp: 21, @@ -103,14 +104,23 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent { } } -function setupProxyAgents({ +async function setupProxyAgents({ requestConfig, proxyMode = 'off', + proxyModeReason = '', proxyConfig, httpsAgentRequestFields, interpolationOptions, timeline }) { + if (timeline) { + let modeMsg = `Proxy mode: ${proxyMode}`; + if (proxyMode === 'pac') modeMsg += ` | PAC URL: ${get(proxyConfig, 'pac.source') || '(empty)'}`; + else if (proxyMode === 'on') modeMsg += ` | ${get(proxyConfig, 'protocol')}://${get(proxyConfig, 'hostname')}:${get(proxyConfig, 'port')}`; + else if (proxyMode === 'off' && proxyModeReason) modeMsg += ` (${proxyModeReason})`; + timeline.push({ timestamp: new Date(), type: 'info', message: modeMsg }); + } + // Clear stale agents so we always recreate them for the current URL // (handles protocol switches, host changes, and proxy-bypass rules on redirects). delete requestConfig.httpAgent; @@ -153,14 +163,6 @@ function setupProxyAgents({ proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; } - if (timeline) { - timeline.push({ - timestamp: new Date(), - type: 'info', - message: `Using proxy: ${proxyProtocol}://${proxyHostname}${uriPort}` - }); - } - // When the proxy itself uses HTTPS, the agent connecting to it needs TLS options // (e.g., ca certs) even for plain HTTP requests const isHttpsProxy = proxyProtocol === 'https'; @@ -218,6 +220,38 @@ function setupProxyAgents({ throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`); } } + } else if (proxyMode === 'pac') { + const pacSource = get(proxyConfig, 'pac.source'); + 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]; + 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)' }); + } + } catch (err) { + if (timeline) timeline.push({ timestamp: new Date(), type: 'error', message: `PAC resolution failed: ${err.message}` }); + } + } } if (!requestConfig.httpAgent && !requestConfig.httpsAgent) { diff --git a/packages/bruno-electron/test/proxy-util.test.js b/packages/bruno-electron/test/proxy-util.test.js new file mode 100644 index 000000000..9dc9fbfff --- /dev/null +++ b/packages/bruno-electron/test/proxy-util.test.js @@ -0,0 +1,148 @@ +const jestClearModules = () => { + jest.resetModules(); + jest.clearAllMocks(); +}; + +/** Mock every external dependency that proxy-util pulls in so tests are isolated. */ +const setupMocks = ({ pacDirectives = ['PROXY p.example:8080'] } = {}) => { + // Preferences — controls SSL session cache flag + jest.doMock('../src/store/preferences', () => ({ + preferencesUtil: { + isSslSessionCachingEnabled: () => false + } + })); + + // @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 () => ({ + resolve: async () => pacDirectives, + dispose: () => {} + })), + clearPacCache: jest.fn() + })); +}; + +describe('proxy-util', () => { + beforeEach(() => jestClearModules()); + afterEach(() => jestClearModules()); + + test('shouldUseProxy respects wildcard bypass', () => { + const { shouldUseProxy } = require('../src/utils/proxy-util'); + expect(shouldUseProxy('http://example.com', '*')).toBe(false); + }); + + test('setupProxyAgents: PAC PROXY directive sets http and https agents', async () => { + setupMocks({ pacDirectives: ['PROXY p.example:8080', 'DIRECT'] }); + const { setupProxyAgents } = require('../src/utils/proxy-util'); + const { getOrCreateHttpAgent, getOrCreateHttpsAgent } = require('@usebruno/requests'); + + const requestConfig = { url: 'http://example.com/resource' }; + const timeline = []; + + await setupProxyAgents({ + requestConfig, + proxyMode: 'pac', + proxyConfig: { pac: { source: 'http://pac-server/proxy.pac' } }, + httpsAgentRequestFields: {}, + interpolationOptions: {}, + timeline + }); + + expect(requestConfig.httpsAgent).toBeDefined(); + expect(requestConfig.httpAgent).toBeDefined(); + expect(getOrCreateHttpsAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: 'http://p.example:8080' })); + expect(getOrCreateHttpAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: 'http://p.example:8080' })); + + const hasPacInfo = timeline.some((t) => t.type === 'info' && /PAC directives/.test(t.message)); + expect(hasPacInfo).toBe(true); + }); + + test('setupProxyAgents: PAC DIRECT directive bypasses proxy and uses fallback agent', async () => { + setupMocks({ pacDirectives: ['DIRECT'] }); + const { setupProxyAgents } = require('../src/utils/proxy-util'); + const { getOrCreateHttpAgent, getOrCreateHttpsAgent } = require('@usebruno/requests'); + + const requestConfig = { url: 'http://example.com/resource' }; + const timeline = []; + + await setupProxyAgents({ + requestConfig, + proxyMode: 'pac', + proxyConfig: { pac: { source: 'http://pac-server/proxy.pac' } }, + httpsAgentRequestFields: {}, + interpolationOptions: {}, + timeline + }); + + // DIRECT → no proxy agents set inside PAC block, fallback sets httpAgent for http request + expect(requestConfig.httpAgent).toBeDefined(); + // httpsAgent should NOT have been set (http request, not https) + expect(requestConfig.httpsAgent).toBeUndefined(); + // Fallback agent called with null proxyUri + expect(getOrCreateHttpAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: null })); + expect(getOrCreateHttpsAgent).not.toHaveBeenCalled(); + }); + + test('setupProxyAgents: PAC SOCKS directive sets socks agents', async () => { + setupMocks({ pacDirectives: ['SOCKS5 socks.example:1080'] }); + const { setupProxyAgents } = require('../src/utils/proxy-util'); + const { getOrCreateHttpAgent, getOrCreateHttpsAgent } = require('@usebruno/requests'); + + const requestConfig = { url: 'http://example.com/resource' }; + const timeline = []; + + await setupProxyAgents({ + requestConfig, + proxyMode: 'pac', + proxyConfig: { pac: { source: 'http://pac-server/proxy.pac' } }, + httpsAgentRequestFields: {}, + interpolationOptions: {}, + timeline + }); + + expect(requestConfig.httpsAgent).toBeDefined(); + expect(requestConfig.httpAgent).toBeDefined(); + expect(getOrCreateHttpsAgent).toHaveBeenCalledWith( + expect.objectContaining({ proxyUri: 'socks5://socks.example:1080' }) + ); + expect(getOrCreateHttpAgent).toHaveBeenCalledWith( + expect.objectContaining({ proxyUri: 'socks5://socks.example:1080' }) + ); + }); + + test('setupProxyAgents: PAC resolution error logs to timeline and falls back to direct agent', async () => { + jest.doMock('../src/store/preferences', () => ({ + preferencesUtil: { isSslSessionCachingEnabled: () => false } + })); + jest.doMock('@usebruno/requests', () => ({ + getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })), + getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })), + getPacResolver: jest.fn(async () => { throw new Error('PAC fetch timeout'); }), + clearPacCache: jest.fn() + })); + + const { setupProxyAgents } = require('../src/utils/proxy-util'); + const { getOrCreateHttpAgent } = require('@usebruno/requests'); + + const requestConfig = { url: 'http://example.com/resource' }; + const timeline = []; + + await setupProxyAgents({ + requestConfig, + proxyMode: 'pac', + proxyConfig: { pac: { source: 'http://unreachable/proxy.pac' } }, + httpsAgentRequestFields: {}, + interpolationOptions: {}, + timeline + }); + + // Error should be logged to timeline + const hasError = timeline.some((t) => t.type === 'error' && /PAC resolution failed/.test(t.message)); + expect(hasError).toBe(true); + // Fallback direct agent should be set for the http request + expect(requestConfig.httpAgent).toBeDefined(); + expect(getOrCreateHttpAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: null })); + }); +}); diff --git a/packages/bruno-electron/tests/store/proxy-preferences.spec.js b/packages/bruno-electron/tests/store/proxy-preferences.spec.js index 927ce2032..70066303a 100644 --- a/packages/bruno-electron/tests/store/proxy-preferences.spec.js +++ b/packages/bruno-electron/tests/store/proxy-preferences.spec.js @@ -15,44 +15,35 @@ const { getPreferences, savePreferences } = require('../../src/store/preferences describe('Proxy Preferences Migration', () => { beforeEach(() => { - // Reset mock store data before each test mockStoreData = {}; }); describe('Default Proxy Settings', () => { - it('should default to inherit: true for new users (empty preferences)', () => { - // New user - no preferences.json exists, store returns empty object + it('should default to source: inherit for new users (empty preferences)', () => { mockStoreData['preferences'] = {}; const preferences = getPreferences(); - // New users get the default proxy settings with inherit: true - expect(preferences.proxy.inherit).toBe(true); + expect(preferences.proxy.source).toBe('inherit'); expect(preferences.proxy.disabled).toBeUndefined(); + expect(preferences.proxy.inherit).toBeUndefined(); expect(preferences.proxy.config).toBeDefined(); expect(preferences.proxy.config.protocol).toBe('http'); expect(preferences.proxy.config.hostname).toBe(''); expect(preferences.proxy.config.port).toBeNull(); }); - it('should default to disabled: true, inherit: false for existing users without proxy settings', () => { - // Existing user - has preferences but no proxy property + it('should default to source: manual, disabled: true for existing users without proxy settings', () => { mockStoreData['preferences'] = { - request: { - sslVerification: true - }, - font: { - codeFont: 'default', - codeFontSize: 13 - } + request: { sslVerification: true }, + font: { codeFont: 'default', codeFontSize: 13 } }; const preferences = getPreferences(); - // Existing users without proxy get disabled proxy by default + expect(preferences.proxy.source).toBe('manual'); expect(preferences.proxy.disabled).toBe(true); - expect(preferences.proxy.inherit).toBe(false); - expect(preferences.proxy.config).toBeDefined(); + expect(preferences.proxy.inherit).toBeUndefined(); expect(preferences.proxy.config.protocol).toBe('http'); expect(preferences.proxy.config.hostname).toBe(''); expect(preferences.proxy.config.port).toBeNull(); @@ -62,279 +53,295 @@ describe('Proxy Preferences Migration', () => { }); }); - describe('New Format (no migration needed)', () => { - it('should handle new format with inherit: false', () => { - const newFormatProxy = { + describe('v3 Format (no migration needed)', () => { + it('should handle source: manual', () => { + mockStoreData['preferences'] = { proxy: { - inherit: false, + source: 'manual', config: { protocol: 'http', hostname: 'proxy.example.com', port: 8080, - auth: { - username: 'user', - password: 'pass' - }, + auth: { username: 'user', password: 'pass' }, bypassProxy: 'localhost' } } }; - mockStoreData['preferences'] = newFormatProxy; - const preferences = getPreferences(); - // Verify key fields are preserved from stored preferences - expect(preferences.proxy.inherit).toBe(false); - expect(preferences.proxy.config.protocol).toBe('http'); + expect(preferences.proxy.source).toBe('manual'); + expect(preferences.proxy.inherit).toBeUndefined(); expect(preferences.proxy.config.hostname).toBe('proxy.example.com'); expect(preferences.proxy.config.port).toBe(8080); - expect(preferences.proxy.config.auth.username).toBe('user'); - expect(preferences.proxy.config.auth.password).toBe('pass'); - expect(preferences.proxy.config.bypassProxy).toBe('localhost'); }); - it('should handle new format with inherit: true', () => { - const newFormatProxy = { + it('should handle source: inherit', () => { + mockStoreData['preferences'] = { proxy: { - inherit: true, - config: { - protocol: 'http', - hostname: '', - port: null, - auth: { - username: '', - password: '' - }, - bypassProxy: '' - } + source: 'inherit', + config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' } } }; - mockStoreData['preferences'] = newFormatProxy; + const preferences = getPreferences(); + + expect(preferences.proxy.source).toBe('inherit'); + expect(preferences.proxy.inherit).toBeUndefined(); + }); + + it('should handle source: pac', () => { + mockStoreData['preferences'] = { + proxy: { + source: 'pac', + pac: { source: 'http://internal/proxy.pac' }, + config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' } + } + }; const preferences = getPreferences(); - expect(preferences.proxy.inherit).toBe(true); - expect(preferences.proxy.config).toBeDefined(); + expect(preferences.proxy.source).toBe('pac'); + expect(preferences.proxy.pac.source).toBe('http://internal/proxy.pac'); + expect(preferences.proxy.inherit).toBeUndefined(); }); - it('should handle new format with disabled: true', () => { - const newFormatProxy = { + it('should handle disabled: true with source: manual', () => { + mockStoreData['preferences'] = { proxy: { disabled: true, - inherit: false, - config: { - protocol: 'http', - hostname: '', - port: null, - auth: { - username: '', - password: '' - }, - bypassProxy: '' - } + source: 'manual', + config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' } } }; - mockStoreData['preferences'] = newFormatProxy; - const preferences = getPreferences(); - // disabled: true is preserved from stored preferences expect(preferences.proxy.disabled).toBe(true); - expect(preferences.proxy.inherit).toBe(false); - expect(preferences.proxy.config).toBeDefined(); + expect(preferences.proxy.source).toBe('manual'); + expect(preferences.proxy.inherit).toBeUndefined(); }); - it('should handle new format with auth.disabled: true', () => { - const newFormatProxy = { + it('should handle auth.disabled: true', () => { + mockStoreData['preferences'] = { proxy: { - inherit: false, + source: 'manual', config: { protocol: 'http', hostname: 'proxy.example.com', port: 8080, - auth: { - disabled: true, - username: 'user', - password: 'pass' - }, + auth: { disabled: true, username: 'user', password: 'pass' }, bypassProxy: '' } } }; - mockStoreData['preferences'] = newFormatProxy; - const preferences = getPreferences(); - // auth.disabled: true is preserved from stored preferences expect(preferences.proxy.config.auth.disabled).toBe(true); expect(preferences.proxy.config.auth.username).toBe('user'); - expect(preferences.proxy.config.auth.password).toBe('pass'); }); }); - describe('Old Format 1: enabled (boolean)', () => { - it('should migrate enabled: true to disabled: false, inherit: false', () => { - const oldFormatProxy = { + describe('v2 → v3 Migration (inherit boolean → source string)', () => { + it('should migrate inherit: true → source: system', () => { + mockStoreData['preferences'] = { + proxy: { + inherit: true, + config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' } + } + }; + + const preferences = getPreferences(); + + expect(preferences.proxy.source).toBe('inherit'); + expect(preferences.proxy.inherit).toBeUndefined(); + }); + + it('should migrate inherit: false (no source) → source: manual', () => { + mockStoreData['preferences'] = { + proxy: { + inherit: false, + config: { protocol: 'http', hostname: 'proxy.example.com', port: 8080, auth: { username: 'user', password: 'pass' }, bypassProxy: '' } + } + }; + + const preferences = getPreferences(); + + expect(preferences.proxy.source).toBe('manual'); + expect(preferences.proxy.inherit).toBeUndefined(); + expect(preferences.proxy.config.hostname).toBe('proxy.example.com'); + }); + + it('should migrate inherit: false, source: pac → source: pac (preserved)', () => { + mockStoreData['preferences'] = { + proxy: { + inherit: false, + source: 'pac', + pac: { source: 'http://internal/proxy.pac' }, + config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' } + } + }; + + const preferences = getPreferences(); + + expect(preferences.proxy.source).toBe('pac'); + expect(preferences.proxy.pac.source).toBe('http://internal/proxy.pac'); + expect(preferences.proxy.inherit).toBeUndefined(); + }); + + it('should save migrated v2 → v3 back to disk', () => { + mockStoreData['preferences'] = { + proxy: { + inherit: true, + config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' } + } + }; + + getPreferences(); + + expect(mockStoreData['preferences'].proxy.inherit).toBeUndefined(); + expect(mockStoreData['preferences'].proxy.source).toBe('inherit'); + }); + }); + + describe('v1 → v3 Migration (enabled boolean)', () => { + it('should migrate enabled: true → source: manual', () => { + mockStoreData['preferences'] = { proxy: { enabled: true, protocol: 'http', hostname: 'proxy.example.com', port: 8080, - auth: { - enabled: true, - username: 'user', - password: 'pass' - }, + auth: { enabled: true, username: 'user', password: 'pass' }, bypassProxy: 'localhost' } }; - mockStoreData['preferences'] = oldFormatProxy; - const preferences = getPreferences(); - // After migration, inherit should be false (old enabled: true maps to inherit: false) - expect(preferences.proxy.inherit).toBe(false); - // Values are preserved from stored preferences - expect(preferences.proxy.config.protocol).toBe('http'); + expect(preferences.proxy.source).toBe('manual'); + expect(preferences.proxy.disabled).toBeUndefined(); + expect(preferences.proxy.inherit).toBeUndefined(); expect(preferences.proxy.config.hostname).toBe('proxy.example.com'); expect(preferences.proxy.config.port).toBe(8080); - expect(preferences.proxy.config.auth.username).toBe('user'); - expect(preferences.proxy.config.auth.password).toBe('pass'); - expect(preferences.proxy.config.bypassProxy).toBe('localhost'); - expect(preferences.proxy.disabled).toBeUndefined(); // disabled: false is omitted }); - it('should migrate enabled: false to disabled: true, inherit: false', () => { - const oldFormatProxy = { + it('should migrate enabled: false → source: manual, disabled: true', () => { + mockStoreData['preferences'] = { proxy: { enabled: false, protocol: 'http', - hostname: 'proxy.example.com', - port: 8080, - auth: { - enabled: false, - username: '', - password: '' - }, + hostname: '', + port: null, + auth: { enabled: false, username: '', password: '' }, bypassProxy: '' } }; - mockStoreData['preferences'] = oldFormatProxy; - const preferences = getPreferences(); - // After migration, enabled: false becomes disabled: true, inherit: false + expect(preferences.proxy.source).toBe('manual'); expect(preferences.proxy.disabled).toBe(true); - expect(preferences.proxy.inherit).toBe(false); + expect(preferences.proxy.inherit).toBeUndefined(); }); - it('should migrate auth.enabled: false to auth.disabled: true', () => { - const oldFormatProxy = { + it('should migrate auth.enabled: false → auth.disabled: true', () => { + mockStoreData['preferences'] = { proxy: { enabled: true, protocol: 'http', hostname: 'proxy.example.com', port: 8080, - auth: { - enabled: false, - username: 'user', - password: 'pass' - }, + auth: { enabled: false, username: 'user', password: 'pass' }, bypassProxy: '' } }; - mockStoreData['preferences'] = oldFormatProxy; - const preferences = getPreferences(); - // auth.disabled: true is preserved from stored preferences expect(preferences.proxy.config.auth.disabled).toBe(true); + expect(preferences.proxy.config.auth.username).toBe('user'); + }); + + it('should save migrated v1 → v3 back to disk', () => { + mockStoreData['preferences'] = { + proxy: { + enabled: true, + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { enabled: true, username: 'user', password: 'pass' }, + bypassProxy: '' + } + }; + + getPreferences(); + + expect(mockStoreData['preferences'].proxy.enabled).toBeUndefined(); + expect(mockStoreData['preferences'].proxy.source).toBe('manual'); }); }); - describe('Old Format 2: mode (string)', () => { - it('should migrate mode: "off" to disabled: true, inherit: false', () => { - const oldFormatProxy = { + describe('v1 → v3 Migration (mode string)', () => { + it('should migrate mode: off → source: manual, disabled: true', () => { + mockStoreData['preferences'] = { proxy: { mode: 'off', protocol: 'http', hostname: '', port: null, - auth: { - enabled: false, - username: '', - password: '' - }, + auth: { enabled: false, username: '', password: '' }, bypassProxy: '' } }; - mockStoreData['preferences'] = oldFormatProxy; - const preferences = getPreferences(); - // disabled: true is preserved from migration + expect(preferences.proxy.source).toBe('manual'); expect(preferences.proxy.disabled).toBe(true); - expect(preferences.proxy.inherit).toBe(false); + expect(preferences.proxy.inherit).toBeUndefined(); }); - it('should migrate mode: "on" to disabled: false, inherit: false', () => { - const oldFormatProxy = { + it('should migrate mode: on → source: manual', () => { + mockStoreData['preferences'] = { proxy: { mode: 'on', protocol: 'https', hostname: 'proxy.example.com', port: 8443, - auth: { - enabled: true, - username: 'user', - password: 'pass' - }, + auth: { enabled: true, username: 'user', password: 'pass' }, bypassProxy: '*.local' } }; - mockStoreData['preferences'] = oldFormatProxy; - const preferences = getPreferences(); - expect(preferences.proxy.disabled).toBeUndefined(); // disabled: false is omitted - expect(preferences.proxy.inherit).toBe(false); - // Values are preserved from stored preferences + expect(preferences.proxy.source).toBe('manual'); + expect(preferences.proxy.disabled).toBeUndefined(); + expect(preferences.proxy.inherit).toBeUndefined(); expect(preferences.proxy.config.protocol).toBe('https'); expect(preferences.proxy.config.hostname).toBe('proxy.example.com'); expect(preferences.proxy.config.port).toBe(8443); }); - it('should migrate mode: "system" to disabled: false, inherit: true', () => { - const oldFormatProxy = { + it('should migrate mode: system → source: inherit', () => { + mockStoreData['preferences'] = { proxy: { mode: 'system', protocol: 'http', hostname: '', port: null, - auth: { - enabled: false, - username: '', - password: '' - }, + auth: { enabled: false, username: '', password: '' }, bypassProxy: '' } }; - mockStoreData['preferences'] = oldFormatProxy; - const preferences = getPreferences(); - expect(preferences.proxy.disabled).toBeUndefined(); // disabled: false is omitted - expect(preferences.proxy.inherit).toBe(true); + expect(preferences.proxy.source).toBe('inherit'); + expect(preferences.proxy.disabled).toBeUndefined(); + expect(preferences.proxy.inherit).toBeUndefined(); }); }); }); diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json index 8ddbb851a..2d26b30f8 100644 --- a/packages/bruno-requests/package.json +++ b/packages/bruno-requests/package.json @@ -25,6 +25,8 @@ "@grpc/proto-loader": "^0.7.15", "@types/qs": "^6.9.18", "axios": "1.13.6", + "pac-resolver": "^7.0.1", + "quickjs-emscripten": "^0.32.0", "debug": "^4.4.3", "google-protobuf": "^4.0.0", "grpc-js-reflection-client": "^1.3.0", diff --git a/packages/bruno-requests/rollup.config.js b/packages/bruno-requests/rollup.config.js index fed1ef6c2..95a91171a 100644 --- a/packages/bruno-requests/rollup.config.js +++ b/packages/bruno-requests/rollup.config.js @@ -39,6 +39,6 @@ module.exports = [ typescript({ tsconfig: './tsconfig.json' }), terser() ], - external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug', 'shell-env'].includes(id) + external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug', 'shell-env', 'pac-resolver', 'quickjs-emscripten'].includes(id) } ]; diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index 581fdda81..6520c4e72 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -10,6 +10,8 @@ export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node export { getHttpHttpsAgents } 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'; +export type { PacWrapper, GetPacResolverParams } from './utils/pac-resolver'; export * as scripting from './scripting'; diff --git a/packages/bruno-requests/src/utils/http-https-agents.ts b/packages/bruno-requests/src/utils/http-https-agents.ts index fe6bbe05a..21153aad3 100644 --- a/packages/bruno-requests/src/utils/http-https-agents.ts +++ b/packages/bruno-requests/src/utils/http-https-agents.ts @@ -12,6 +12,7 @@ import { isEmpty, get, isUndefined, isNull } from 'lodash'; import { getCACertificates } from './ca-cert'; import { transformProxyConfig } from './proxy-util'; import { getOrCreateHttpsAgent, getOrCreateHttpAgent } from './agent-cache'; +import { getPacResolver } from './pac-resolver'; import type { TimelineEntry } from './timeline-agent'; const DEFAULT_PORTS: Record = { @@ -23,7 +24,7 @@ const DEFAULT_PORTS: Record = { wss: 443 }; -type ProxyMode = 'on' | 'off' | 'system'; +type ProxyMode = 'on' | 'off' | 'system' | 'pac'; type ProxyAuth = { enabled: boolean; @@ -39,6 +40,9 @@ type ProxyConfig = { auth?: ProxyAuth; bypassProxy?: string; mode?: ProxyMode; + pac?: { + source: string; + }; }; type SystemProxyConfig = { @@ -309,7 +313,7 @@ const getCertsAndProxyConfig = ({ /** * Proxy configuration * - * Preferences proxyMode has three possible values: on, off, system + * Preferences proxyMode has four possible values: on, off, system, pac * Collection proxyMode has three possible values: true, false, global * * When collection proxyMode is true, it overrides the app-level proxy settings @@ -337,18 +341,22 @@ const getCertsAndProxyConfig = ({ // Inherit from app-level proxy settings if (appLevelProxyConfig) { const globalDisabled = get(appLevelProxyConfig, 'disabled', false); - const globalInherit = get(appLevelProxyConfig, 'inherit', false); - const globalProxyConfigData = get(appLevelProxyConfig, 'config', appLevelProxyConfig); + const globalProxySource = get(appLevelProxyConfig, 'source', 'inherit'); + const globalProxyConfigData = get(appLevelProxyConfig, 'config', {}); - if (!globalDisabled && !globalInherit) { - // Use app-level custom proxy - proxyConfig = globalProxyConfigData; - proxyMode = 'on'; - } else if (!globalDisabled && globalInherit) { - // App-level also inherits, fall through to system proxy - const { http_proxy, https_proxy } = systemProxyConfig || {}; - if (http_proxy?.length || https_proxy?.length) { - proxyMode = 'system'; + if (!globalDisabled) { + if (globalProxySource === 'pac') { + 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) { + proxyMode = 'system'; + } + } else { + // source === 'manual' + proxyConfig = globalProxyConfigData; + proxyMode = 'on'; } } // else: app-level proxy is disabled, proxyMode stays 'off' @@ -374,7 +382,7 @@ function extractHostname(url: string | undefined): string | null { } } -function createAgents({ +async function createAgents({ requestUrl, proxyMode, proxyConfig, @@ -383,7 +391,7 @@ function createAgents({ httpsAgentRequestFields, timeline, disableCache = true -}: CreateAgentsParams): AgentResult { +}: CreateAgentsParams): Promise { // Ensure TLS options are properly set const tlsOptions: TlsOptions = { ...httpsAgentRequestFields, @@ -447,6 +455,40 @@ function createAgents({ } } } + } else if (proxyMode === 'pac') { + 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 }); + } + } + } + } catch { + // PAC resolution failed — fall through to direct connection + } + } } else if (proxyMode === 'system') { const http_proxy = get(systemProxyConfig, 'http_proxy'); const https_proxy = get(systemProxyConfig, 'https_proxy'); @@ -514,7 +556,7 @@ const getHttpHttpsAgents = async ({ httpsAgentRequestFields.rejectUnauthorized = false; } - const { httpAgent, httpsAgent } = createAgents({ + const { httpAgent, httpsAgent } = await createAgents({ requestUrl, proxyMode, proxyConfig, diff --git a/packages/bruno-requests/src/utils/node-vault.ts b/packages/bruno-requests/src/utils/node-vault.ts index b4e2d31e9..7ceeef55b 100644 --- a/packages/bruno-requests/src/utils/node-vault.ts +++ b/packages/bruno-requests/src/utils/node-vault.ts @@ -188,6 +188,7 @@ function createVaultClient(config: VaultConfig = {}): VaultClient { method: method as any, url: uri, headers, + proxy: false, validateStatus: () => true // Don't throw on non-2xx status }; diff --git a/packages/bruno-requests/src/utils/pac-resolver.spec.ts b/packages/bruno-requests/src/utils/pac-resolver.spec.ts new file mode 100644 index 000000000..46689b13e --- /dev/null +++ b/packages/bruno-requests/src/utils/pac-resolver.spec.ts @@ -0,0 +1,281 @@ +describe('pac-resolver (shared)', () => { + beforeEach(() => { + jest.resetModules(); + }); + + afterEach(() => { + const { clearPacCache } = require('./pac-resolver'); + clearPacCache(); + jest.clearAllMocks(); + }); + + /** Mock pac-resolver (v7: { createPacResolver }) and quickjs-emscripten */ + const setupPacMocks = (resolverFn: (...args: any[]) => Promise = async () => 'PROXY p.example:8080; DIRECT') => { + jest.doMock('quickjs-emscripten', () => ({ + getQuickJS: jest.fn(async () => ({})) + })); + const createPacResolverMock = jest.fn((_qjs: any, _script: any) => resolverFn); + jest.doMock('pac-resolver', () => ({ createPacResolver: createPacResolverMock })); + return { createPacResolverMock }; + }; + + const mockFsReadSuccess = (content: string) => { + jest.doMock('fs/promises', () => ({ + readFile: jest.fn().mockResolvedValue(content) + })); + }; + + const mockFsReadError = (err: Error) => { + jest.doMock('fs/promises', () => ({ + readFile: jest.fn().mockRejectedValue(err) + })); + }; + + const mockAxiosSuccess = (text: string) => { + jest.doMock('axios', () => ({ get: jest.fn().mockResolvedValue({ data: text }) })); + }; + + const mockAxiosHttpError = (status: number) => { + const err = Object.assign(new Error(`Request failed with status code ${status}`), { + response: { status } + }); + jest.doMock('axios', () => ({ get: jest.fn().mockRejectedValue(err) })); + }; + + const mockAxiosNetworkError = (message: string) => { + jest.doMock('axios', () => ({ get: jest.fn().mockRejectedValue(new Error(message)) })); + }; + + test('throws when pacSource is not provided', async () => { + const { getPacResolver } = require('./pac-resolver'); + await expect(getPacResolver({})).rejects.toThrow('pacSource must be provided'); + }); + + test('downloads PAC via axios and returns resolver that splits directives', async () => { + const pacScript = 'function FindProxyForURL(url, host) { return "PROXY p.example:8080; DIRECT"; }'; + mockAxiosSuccess(pacScript); + const { createPacResolverMock } = setupPacMocks(async () => 'PROXY p.example:8080; DIRECT'); + + const { getPacResolver } = require('./pac-resolver'); + const { get: axiosGet } = require('axios'); + + const pacSource = 'http://example.com/proxy.pac'; + const wrapper = await getPacResolver({ pacSource }); + + const directives = await wrapper.resolve('http://foo.example/'); + expect(directives).toEqual(['PROXY p.example:8080', 'DIRECT']); + expect(createPacResolverMock).toHaveBeenCalledWith(expect.any(Object), pacScript); + expect(axiosGet).toHaveBeenCalledWith(pacSource, expect.objectContaining({ proxy: false })); + }); + + test('passes TLS options to https.Agent for HTTPS pac URLs', async () => { + mockAxiosSuccess('script'); + setupPacMocks(async () => 'DIRECT'); + + const mockAgentConstructor = jest.fn(); + jest.doMock('https', () => ({ Agent: mockAgentConstructor })); + + const { getPacResolver } = require('./pac-resolver'); + + const httpsAgentRequestFields = { + ca: 'ca-cert-data', + rejectUnauthorized: false, + minVersion: 'TLSv1.2' + }; + await getPacResolver({ pacSource: 'https://secure.example.com/proxy.pac', httpsAgentRequestFields }); + + expect(mockAgentConstructor).toHaveBeenCalledWith({ + ca: 'ca-cert-data', + rejectUnauthorized: false, + minVersion: 'TLSv1.2' + }); + }); + + test('does not create https.Agent for HTTP pac URLs', async () => { + mockAxiosSuccess('script'); + setupPacMocks(async () => 'DIRECT'); + + const mockAgentConstructor = jest.fn(); + jest.doMock('https', () => ({ Agent: mockAgentConstructor })); + + const { getPacResolver } = require('./pac-resolver'); + await getPacResolver({ pacSource: 'http://example.com/proxy.pac' }); + + expect(mockAgentConstructor).not.toHaveBeenCalled(); + }); + + test('caches resolver and returns same wrapper on repeated calls', async () => { + mockAxiosSuccess('script'); + const { createPacResolverMock } = setupPacMocks(async () => 'DIRECT'); + + const { getPacResolver, _CACHE } = require('./pac-resolver'); + const pacSource = 'http://example.com/proxy.pac'; + + const w1 = await getPacResolver({ pacSource }); + const w2 = await getPacResolver({ pacSource }); + + expect(w1).toBe(w2); + expect(_CACHE.size).toBeGreaterThan(0); + expect(createPacResolverMock).toHaveBeenCalledTimes(1); + }); + + test('returns empty array when resolver returns non-string', async () => { + mockAxiosSuccess('script'); + setupPacMocks(async () => null); + + const { getPacResolver } = require('./pac-resolver'); + const wrapper = await getPacResolver({ pacSource: 'http://example.com/proxy.pac' }); + expect(await wrapper.resolve('http://example.com/')).toEqual([]); + }); + + test('rejects when axios throws a network error', async () => { + mockAxiosNetworkError('ECONNREFUSED'); + jest.doMock('pac-resolver', () => ({ createPacResolver: jest.fn() })); + jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) })); + + const { getPacResolver } = require('./pac-resolver'); + await expect(getPacResolver({ pacSource: 'http://unreachable/proxy.pac' })).rejects.toThrow('ECONNREFUSED'); + }); + + test('rejects with readable message when PAC server returns non-2xx', async () => { + mockAxiosHttpError(404); + jest.doMock('pac-resolver', () => ({ createPacResolver: jest.fn() })); + jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) })); + + const { getPacResolver } = require('./pac-resolver'); + await expect(getPacResolver({ pacSource: 'http://example.com/missing.pac' })).rejects.toThrow('Failed to fetch PAC (404)'); + }); + + test('re-downloads PAC after cache TTL expires', async () => { + const axiosGetMock = jest.fn().mockResolvedValue({ data: 'script' }); + jest.doMock('axios', () => ({ get: axiosGetMock })); + const { createPacResolverMock } = setupPacMocks(async () => 'DIRECT'); + + const { getPacResolver } = require('./pac-resolver'); + const pacSource = 'http://example.com/proxy.pac'; + const ttlMs = 100; + + const w1 = await getPacResolver({ pacSource, opts: { cacheTtlMs: ttlMs } }); + expect(axiosGetMock).toHaveBeenCalledTimes(1); + + const realNow = Date.now; + Date.now = () => realNow() + ttlMs + 1; + try { + const w2 = await getPacResolver({ pacSource, opts: { cacheTtlMs: ttlMs } }); + expect(axiosGetMock).toHaveBeenCalledTimes(2); + expect(w2).not.toBe(w1); + } finally { + Date.now = realNow; + } + }); + + test('resolve propagates error from a malformed PAC script', async () => { + mockAxiosSuccess('not valid JS {{{{'); + setupPacMocks(async () => { throw new Error('invalid PAC script'); }); + + const { getPacResolver } = require('./pac-resolver'); + const wrapper = await getPacResolver({ pacSource: 'http://example.com/bad.pac' }); + await expect(wrapper.resolve('http://example.com/')).rejects.toThrow('invalid PAC script'); + }); + + /** file:// PAC tests */ + test('reads PAC from filesystem for file:// URL and does not call axios', async () => { + const pacScript = 'function FindProxyForURL(url, host) { return "PROXY p.example:8080"; }'; + const expectedPath = '/Users/test/proxy.pac'; + mockFsReadSuccess(pacScript); + const { createPacResolverMock } = setupPacMocks(async () => 'PROXY p.example:8080'); + const axiosGetMock = jest.fn(); + jest.doMock('axios', () => ({ get: axiosGetMock })); + jest.doMock('url', () => ({ fileURLToPath: jest.fn(() => expectedPath) })); + + const { getPacResolver } = require('./pac-resolver'); + const { readFile } = require('fs/promises'); + + const pacSource = 'file:///Users/test/proxy.pac'; + const wrapper = await getPacResolver({ pacSource }); + + expect(readFile).toHaveBeenCalledWith(expectedPath, 'utf8'); + expect(axiosGetMock).not.toHaveBeenCalled(); + expect(createPacResolverMock).toHaveBeenCalledWith(expect.any(Object), pacScript); + + const directives = await wrapper.resolve('http://foo.example/'); + expect(directives).toEqual(['PROXY p.example:8080']); + }); + + test('resolves Windows file:// URL to correct OS path', async () => { + const pacScript = 'function FindProxyForURL(url, host) { return "DIRECT"; }'; + mockFsReadSuccess(pacScript); + setupPacMocks(async () => 'DIRECT'); + + jest.doMock('url', () => ({ + fileURLToPath: jest.fn(() => 'C:\\Users\\test\\proxy.pac') + })); + + const { getPacResolver } = require('./pac-resolver'); + const { readFile } = require('fs/promises'); + const { fileURLToPath } = require('url'); + + await getPacResolver({ pacSource: 'file:///C:/Users/test/proxy.pac' }); + + expect(fileURLToPath).toHaveBeenCalledWith('file:///C:/Users/test/proxy.pac'); + expect(readFile).toHaveBeenCalledWith('C:\\Users\\test\\proxy.pac', 'utf8'); + }); + + test('rejects when file:// PAC file does not exist', async () => { + const err = Object.assign(new Error('no such file or directory'), { code: 'ENOENT' }); + mockFsReadError(err); + jest.doMock('pac-resolver', () => ({ createPacResolver: jest.fn() })); + jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) })); + + const { getPacResolver } = require('./pac-resolver'); + await expect(getPacResolver({ pacSource: 'file:///nonexistent/proxy.pac' })).rejects.toThrow('no such file or directory'); + }); + + test('caches resolver for file:// URL and reads file only once', async () => { + mockFsReadSuccess('script'); + const { createPacResolverMock } = setupPacMocks(async () => 'DIRECT'); + + const { getPacResolver } = require('./pac-resolver'); + const { readFile } = require('fs/promises'); + + const pacSource = 'file:///Users/test/proxy.pac'; + const w1 = await getPacResolver({ pacSource }); + const w2 = await getPacResolver({ pacSource }); + + expect(w1).toBe(w2); + expect(readFile).toHaveBeenCalledTimes(1); + expect(createPacResolverMock).toHaveBeenCalledTimes(1); + }); + + test('does not create https.Agent for file:// URL', async () => { + mockFsReadSuccess('script'); + setupPacMocks(async () => 'DIRECT'); + + const mockAgentConstructor = jest.fn(); + jest.doMock('https', () => ({ Agent: mockAgentConstructor })); + + const { getPacResolver } = require('./pac-resolver'); + await getPacResolver({ pacSource: 'file:///Users/test/proxy.pac' }); + + expect(mockAgentConstructor).not.toHaveBeenCalled(); + }); + + test('clearPacCache clears entries by prefix and entirely', async () => { + mockAxiosSuccess('script'); + setupPacMocks(async () => 'DIRECT'); + + const { getPacResolver, _CACHE, clearPacCache } = require('./pac-resolver'); + await getPacResolver({ pacSource: 'http://one/pac' }); + await getPacResolver({ pacSource: 'http://two/pac' }); + + expect(_CACHE.size).toBeGreaterThanOrEqual(2); + + clearPacCache('url:http://one'); + for (const key of Array.from(_CACHE.keys()) as string[]) { + expect(key.startsWith('url:http://one')).toBe(false); + } + + clearPacCache(); + expect(_CACHE.size).toBe(0); + }); +}); diff --git a/packages/bruno-requests/src/utils/pac-resolver.ts b/packages/bruno-requests/src/utils/pac-resolver.ts new file mode 100644 index 000000000..8b78ceeb7 --- /dev/null +++ b/packages/bruno-requests/src/utils/pac-resolver.ts @@ -0,0 +1,118 @@ +import axios from 'axios'; +import crypto from 'node:crypto'; +import { readFile } from 'fs/promises'; +import https, { type AgentOptions } from 'https'; +import { fileURLToPath } from 'url'; +import { createPacResolver } from 'pac-resolver'; +import { getQuickJS } from 'quickjs-emscripten'; + +const CACHE = new Map; ts: number }>(); + +type TlsOptions = { + ca?: string | string[]; + rejectUnauthorized?: boolean; + minVersion?: string; +}; + +export type PacWrapper = { + resolve: (url: string) => Promise; +}; + +async function downloadPac(pacSource: string, tlsOptions: TlsOptions, timeoutMs: number): Promise { + if (pacSource.startsWith('file://')) { + return readFile(fileURLToPath(pacSource), 'utf8'); + } + + const config: Record = { + timeout: timeoutMs, + proxy: false, + responseType: 'text', + maxRedirects: 3 + }; + + if (pacSource.startsWith('https://')) { + const agentOpts: AgentOptions = { + ca: tlsOptions.ca, + rejectUnauthorized: tlsOptions.rejectUnauthorized, + minVersion: tlsOptions.minVersion as AgentOptions['minVersion'] + }; + config.httpsAgent = new https.Agent(agentOpts); + } + + try { + const response = await axios.get(pacSource, config); + return response.data; + } catch (err: any) { + if (err.response) throw new Error(`Failed to fetch PAC (${err.response.status})`); + throw err; + } +} + +export type GetPacResolverParams = { + pacSource: string; + httpsAgentRequestFields?: TlsOptions; + opts?: { cacheTtlMs?: number; timeoutMs?: number }; +}; + +export async function getPacResolver({ pacSource, httpsAgentRequestFields = {}, opts = {} }: GetPacResolverParams): Promise { + if (!pacSource) throw new Error('pacSource must be provided'); + + const cacheTtlMs = opts.cacheTtlMs ?? 5 * 60 * 1000; + let key: string; + if (pacSource.startsWith('https://')) { + const caRaw = httpsAgentRequestFields.ca; + const caHash = caRaw + ? crypto.createHash('sha256').update(Array.isArray(caRaw) ? caRaw.join('|') : caRaw).digest('hex').slice(0, 16) + : ''; + key = `url:${pacSource}|ca:${caHash}|ru:${httpsAgentRequestFields.rejectUnauthorized ?? ''}|mv:${httpsAgentRequestFields.minVersion ?? ''}`; + } else { + // file:// and http:// — no TLS options involved in fetching + key = `url:${pacSource}`; + } + const now = Date.now(); + const cached = CACHE.get(key); + if (cached && now - cached.ts < cacheTtlMs) return cached.wrapper; + + const wrapperPromise: Promise = (async () => { + const script = await downloadPac(pacSource, httpsAgentRequestFields, opts.timeoutMs ?? 5000); + + // pac-resolver v7 uses QuickJS WASM sandbox — not affected by CVE GHSA-9j49-mfvp-vmhm ( { + let host: string; + try { + host = new URL(url).hostname; + } catch { + return []; + } + const out = await resolverFn(url, host); + if (!out || typeof out !== 'string') return []; + return out.split(';').map((s) => s.trim()).filter(Boolean); + } + }; + })(); + + CACHE.set(key, { wrapper: wrapperPromise, ts: now }); + + try { + return await wrapperPromise; + } catch (err) { + CACHE.delete(key); + throw err; + } +} + +export function clearPacCache(keyPrefix?: string): void { + if (!keyPrefix) { + CACHE.clear(); + return; + } + for (const key of Array.from(CACHE.keys())) { + if (key.startsWith(keyPrefix)) CACHE.delete(key); + } +} + +export const _CACHE = CACHE; diff --git a/packages/bruno-requests/src/utils/proxy-util.spec.ts b/packages/bruno-requests/src/utils/proxy-util.spec.ts index 27050dac8..6ae82f798 100644 --- a/packages/bruno-requests/src/utils/proxy-util.spec.ts +++ b/packages/bruno-requests/src/utils/proxy-util.spec.ts @@ -200,6 +200,63 @@ describe('transformProxyConfig', () => { expect(result).toEqual(newConfig); }); + + // Backward compat: old manual users have no source field — pass through unchanged + test('should not modify new format without source field (backward compat: treated as manual)', () => { + const newConfig = { + inherit: false, + config: { + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { username: 'user', password: 'pass' }, + bypassProxy: '' + } + }; + + const result = transformProxyConfig(newConfig); + + expect(result).toEqual(newConfig); + expect((result as any).source).toBeUndefined(); + }); + + test('should not modify new format with source: manual', () => { + const newConfig = { + inherit: false, + source: 'manual', + pac: { source: '' }, + config: { + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { username: 'user', password: 'pass' }, + bypassProxy: '' + } + }; + + const result = transformProxyConfig(newConfig); + + expect(result).toEqual(newConfig); + }); + + test('should not modify new format with source: pac', () => { + const newConfig = { + inherit: false, + source: 'pac', + pac: { source: 'http://internal/proxy.pac' }, + config: { + protocol: 'http', + hostname: '', + port: null, + auth: { username: '', password: '' }, + bypassProxy: '' + } + }; + + const result = transformProxyConfig(newConfig); + + expect(result).toEqual(newConfig); + }); }); describe('Edge Cases', () => { diff --git a/tests/proxy/pac/fixtures/collection/bruno.json b/tests/proxy/pac/fixtures/collection/bruno.json new file mode 100644 index 000000000..08fdb8a51 --- /dev/null +++ b/tests/proxy/pac/fixtures/collection/bruno.json @@ -0,0 +1,6 @@ +{ + "version": "1", + "name": "pac-proxy-test", + "type": "collection", + "ignore": [] +} diff --git a/tests/proxy/pac/fixtures/collection/direct.bru b/tests/proxy/pac/fixtures/collection/direct.bru new file mode 100644 index 000000000..9ee1a745d --- /dev/null +++ b/tests/proxy/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 (PAC returned DIRECT)", function() { + expect(res.headers['x-proxied']).to.be.undefined; + }); +} diff --git a/tests/proxy/pac/fixtures/collection/proxied.bru b/tests/proxy/pac/fixtures/collection/proxied.bru new file mode 100644 index 000000000..d93681d23 --- /dev/null +++ b/tests/proxy/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 PAC proxy", function() { + expect(res.headers['x-proxied']).to.equal('test-proxy'); + }); +} diff --git a/tests/proxy/pac/fixtures/pac-files/test.pac b/tests/proxy/pac/fixtures/pac-files/test.pac new file mode 100644 index 000000000..9c8e1b9b4 --- /dev/null +++ b/tests/proxy/pac/fixtures/pac-files/test.pac @@ -0,0 +1,6 @@ +function FindProxyForURL(url, host) { + if (url.indexOf('/proxied') !== -1) { + return 'PROXY localhost:18888'; + } + return 'DIRECT'; +} diff --git a/tests/proxy/pac/init-user-data/preferences.json b/tests/proxy/pac/init-user-data/preferences.json new file mode 100644 index 000000000..d30c6d177 --- /dev/null +++ b/tests/proxy/pac/init-user-data/preferences.json @@ -0,0 +1,17 @@ +{ + "maximized": false, + "lastOpenedCollections": ["{{projectRoot}}/tests/proxy/pac/fixtures/collection"], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + }, + "proxy": { + "source": "pac", + "pac": { + "source": "{{pacUrl}}" + }, + "config": {} + } + } +} diff --git a/tests/proxy/pac/pac-proxy.spec.ts b/tests/proxy/pac/pac-proxy.spec.ts new file mode 100644 index 000000000..9adb287a4 --- /dev/null +++ b/tests/proxy/pac/pac-proxy.spec.ts @@ -0,0 +1,68 @@ +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { test } from '../../../playwright'; +import { setSandboxMode, runCollection, validateRunnerResults } from '../../utils/page'; +import { startServers, stopServers, PAC_PORT, type TestServers } from './server'; + +test.describe('PAC Proxy', () => { + let servers: TestServers; + + test.beforeAll(async () => { + servers = await startServers(); + }); + + test.afterAll(async () => { + if (servers) { + await stopServers(servers); + } + }); + + /** + * Verifies end-to-end PAC proxy resolution: + * + * - The PAC file routes /proxied paths to the local test proxy (port 18888). + * - The local test proxy injects `x-proxied: test-proxy` into every response. + * - /direct paths are returned DIRECT — no proxy header added. + * + * Both assertions live inside the collection's `tests {}` blocks, so + * validateRunnerResults confirms the full flow passed. + */ + test('routes requests per PAC directive (PROXY and DIRECT) via HTTP URL', async ({ launchElectronApp }) => { + const pacUrl = `http://localhost:${PAC_PORT}/test.pac`; + const initUserDataPath = path.join(__dirname, 'init-user-data'); + const app = await launchElectronApp({ initUserDataPath, templateVars: { pacUrl } }); + + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await setSandboxMode(page, 'pac-proxy-test', 'developer'); + await runCollection(page, 'pac-proxy-test'); + await validateRunnerResults(page, { + totalRequests: 2, + passed: 2, + failed: 0, + skipped: 0 + }); + }); + + test('routes requests via file:// PAC URL', async ({ launchElectronApp }) => { + // Compute the file:// URL at runtime so it is correct on every OS: + // Mac/Linux → file:///abs/path/to/test.pac + // Windows → file:///C:/abs/path/to/test.pac + const pacUrl = pathToFileURL(path.join(__dirname, 'fixtures', 'pac-files', 'test.pac')).href; + const initUserDataPath = path.join(__dirname, 'init-user-data'); + const app = await launchElectronApp({ initUserDataPath, templateVars: { pacUrl } }); + + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await setSandboxMode(page, 'pac-proxy-test', 'developer'); + await runCollection(page, 'pac-proxy-test'); + await validateRunnerResults(page, { + totalRequests: 2, + passed: 2, + failed: 0, + skipped: 0 + }); + }); +}); diff --git a/tests/proxy/pac/server/index.ts b/tests/proxy/pac/server/index.ts new file mode 100644 index 000000000..23abf873d --- /dev/null +++ b/tests/proxy/pac/server/index.ts @@ -0,0 +1,113 @@ +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; + +export const PAC_PORT = 18080; +export const PROXY_PORT = 18888; +export const TARGET_PORT = 19000; + +export interface TestServers { + pacServer: http.Server; + proxyServer: http.Server; + targetServer: http.Server; +} + +/** Serves .pac files from the pac-files/ directory. */ +function createPacServer(): Promise { + const pacDir = path.join(__dirname, 'pac-files'); + const server = http.createServer((req, res) => { + const filename = (req.url ?? '/').replace(/^\//, '') || 'test.pac'; + const filepath = path.join(pacDir, filename); + fs.readFile(filepath, 'utf8', (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not found'); + return; + } + res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' }); + res.end(data); + }); + }); + return listen(server, PAC_PORT); +} + +/** + * Plain HTTP proxy. Forwards requests and injects `x-proxied: test-proxy` + * into every response so tests can confirm traffic went through it. + */ +function createProxyServer(): Promise { + const server = http.createServer((clientReq, clientRes) => { + let targetUrl: URL; + try { + targetUrl = new URL(clientReq.url!); + } catch { + clientRes.writeHead(400); + clientRes.end('Bad request URL'); + return; + } + + const options: http.RequestOptions = { + hostname: targetUrl.hostname, + port: targetUrl.port || 80, + path: targetUrl.pathname + targetUrl.search, + method: clientReq.method, + headers: { ...clientReq.headers, host: targetUrl.host } + }; + delete (options.headers as Record)['proxy-connection']; + + const proxyReq = http.request(options, (proxyRes) => { + const headers = { ...proxyRes.headers, 'x-proxied': 'test-proxy' }; + clientRes.writeHead(proxyRes.statusCode!, headers); + proxyRes.pipe(clientRes); + }); + + proxyReq.on('error', (err) => { + if (!clientRes.headersSent) { + clientRes.writeHead(502); + } + clientRes.end(`Proxy error: ${err.message}`); + }); + + clientReq.pipe(proxyReq); + }); + return listen(server, PROXY_PORT); +} + +/** Simple JSON echo server — the requests' target. */ +function createTargetServer(): Promise { + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: req.url })); + }); + return listen(server, TARGET_PORT); +} + +function listen(server: http.Server, port: number): Promise { + return new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +function close(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +} + +export async function startServers(): Promise { + const [pacServer, proxyServer, targetServer] = await Promise.all([ + createPacServer(), + createProxyServer(), + createTargetServer() + ]); + return { pacServer, proxyServer, targetServer }; +} + +export async function stopServers(servers: TestServers): Promise { + await Promise.all([ + close(servers.pacServer), + close(servers.proxyServer), + close(servers.targetServer) + ]); +} diff --git a/tests/proxy/pac/server/pac-files/test.pac b/tests/proxy/pac/server/pac-files/test.pac new file mode 100644 index 000000000..8a471b3ac --- /dev/null +++ b/tests/proxy/pac/server/pac-files/test.pac @@ -0,0 +1,7 @@ +function FindProxyForURL(url, host) { + // Route requests whose path starts with /proxied through the local test proxy + if (url.indexOf("/proxied") > -1) { + return "PROXY 127.0.0.1:18888"; + } + return "DIRECT"; +}