diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fddeae6e6..a7c8969da 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,9 @@ # Description + # Contribution Checklist: + - [ ] **The pull request does not introduce any breaking changes** - [ ] **I have read the [contribution guidelines](https://github.com/usebruno/bruno/blob/main/contributing.md).** - [ ] **Create an issue and link to the pull request.** diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js index ca15cb3a5..f759af2e3 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js @@ -1,7 +1,7 @@ import 'github-markdown-css/github-markdown.css'; import get from 'lodash/get'; import { updateCollectionDocs } from 'providers/ReduxStore/slices/collections'; -import { useTheme } from 'providers/Theme/index'; +import { useTheme } from 'providers/Theme'; import { useState } from 'react'; import { useDispatch } from 'react-redux'; import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions'; diff --git a/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js index 8bd1ece42..3f0981f8d 100644 --- a/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js @@ -1,13 +1,55 @@ import React, { useEffect } from 'react'; import { useFormik } from 'formik'; -import * as Yup from 'yup'; import StyledWrapper from './StyledWrapper'; +import * as Yup from 'yup'; +import toast from 'react-hot-toast'; const ProxySettings = ({ proxyConfig, onUpdate }) => { + const proxySchema = Yup.object({ + enabled: Yup.string().oneOf(['global', 'enabled', 'disabled']), + protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']), + hostname: Yup.string() + .when('enabled', { + is: 'enabled', + then: (hostname) => hostname.required('Specify the hostname for your proxy.'), + otherwise: (hostname) => hostname.nullable() + }) + .max(1024), + port: Yup.number() + .when('enabled', { + is: 'enabled', + then: (port) => port.required('Specify port between 1 and 65535').typeError('Specify port between 1 and 65535'), + otherwise: (port) => port.nullable().transform((_, val) => (val ? Number(val) : null)) + }) + .min(1) + .max(65535), + auth: Yup.object() + .when('enabled', { + is: 'enabled', + then: Yup.object({ + enabled: Yup.boolean(), + username: Yup.string() + .when(['enabled'], { + is: true, + then: (username) => username.required('Specify username for proxy authentication.') + }) + .max(1024), + password: Yup.string() + .when('enabled', { + is: true, + then: (password) => password.required('Specify password for proxy authentication.') + }) + .max(1024) + }) + }) + .optional(), + noProxy: Yup.string().optional().max(1024) + }); + const formik = useFormik({ initialValues: { - enabled: proxyConfig.enabled || false, + enabled: proxyConfig.enabled || 'global', protocol: proxyConfig.protocol || 'http', hostname: proxyConfig.hostname || '', port: proxyConfig.port || '', @@ -15,27 +57,26 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => { enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false, username: proxyConfig.auth ? proxyConfig.auth.username || '' : '', password: proxyConfig.auth ? proxyConfig.auth.password || '' : '' - } + }, + noProxy: proxyConfig.noProxy || '' }, - validationSchema: Yup.object({ - enabled: Yup.boolean(), - protocol: Yup.string().oneOf(['http', 'https', 'socks5']), - hostname: Yup.string().max(1024), - port: Yup.number().min(0).max(65535), - auth: Yup.object({ - enabled: Yup.boolean(), - username: Yup.string().max(1024), - password: Yup.string().max(1024) - }) - }), + validationSchema: proxySchema, onSubmit: (values) => { - onUpdate(values); + proxySchema + .validate(values, { abortEarly: true }) + .then((validatedProxy) => { + onUpdate(validatedProxy); + }) + .catch((error) => { + let errMsg = error.message || 'Preferences validation error'; + toast.error(errMsg); + }); } }); useEffect(() => { formik.setValues({ - enabled: proxyConfig.enabled || false, + enabled: proxyConfig.enabled || 'global', protocol: proxyConfig.protocol || 'http', hostname: proxyConfig.hostname || '', port: proxyConfig.port || '', @@ -43,18 +84,61 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => { enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false, username: proxyConfig.auth ? proxyConfig.auth.username || '' : '', password: proxyConfig.auth ? proxyConfig.auth.password || '' : '' - } + }, + noProxy: proxyConfig.noProxy || '' }); }, [proxyConfig]); return ( +

Proxy Settings

+
- +
+ + + +
+
@@ -132,7 +227,9 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => { onChange={formik.handleChange} value={formik.values.port} /> - {formik.touched.port && formik.errors.port ?
{formik.errors.port}
: null} + {formik.touched.port && formik.errors.port ? ( +
{formik.errors.port}
+ ) : null}
@@ -183,10 +280,30 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => { onChange={formik.handleChange} /> {formik.touched.auth?.password && formik.errors.auth?.password ? ( -
{formik.errors.auth.password}
+
{formik.errors.auth.password}
) : null}
+
+ + + {formik.touched.noProxy && formik.errors.noProxy ? ( +
{formik.errors.noProxy}
+ ) : null} +
+
+
+
+ ); +}; + +export default ProxySettings; diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js index 217b3c57a..843fd8228 100644 --- a/packages/bruno-app/src/components/Preferences/index.js +++ b/packages/bruno-app/src/components/Preferences/index.js @@ -5,6 +5,7 @@ import Support from './Support'; import General from './General'; import Font from './Font'; import Theme from './Theme'; +import Proxy from './ProxySettings'; import StyledWrapper from './StyledWrapper'; const Preferences = ({ onClose }) => { @@ -22,6 +23,10 @@ const Preferences = ({ onClose }) => { return ; } + case 'proxy': { + return ; + } + case 'theme': { return ; } @@ -49,6 +54,9 @@ const Preferences = ({ onClose }) => {
setTab('font')}> Font
+
setTab('proxy')}> + Proxy +
setTab('support')}> Support
diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 8952e1986..c54c3338e 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -45,6 +45,8 @@ export const fetchGqlSchema = async (endpoint, environment, request, collection) export const cancelNetworkRequest = async (cancelTokenUid) => { return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('cancel-http-request', cancelTokenUid).then(resolve).catch(reject); }); }; diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 78b7226ca..4ddad0691 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -162,9 +162,7 @@ const getCollectionRoot = (dir) => { } const content = fs.readFileSync(collectionRootPath, 'utf8'); - const json = collectionBruToJson(content); - - return json; + return collectionBruToJson(content); }; const builder = async (yargs) => { diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index ee67f60b3..59339c0cb 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -16,6 +16,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); const { makeAxiosInstance } = require('../utils/axios-instance'); +const { shouldUseProxy } = require('../utils/proxy-util'); const runSingleRequest = async function ( filename, @@ -47,7 +48,7 @@ const runSingleRequest = async function ( // run pre-request vars const preRequestVars = get(bruJson, 'request.vars.req'); - if (preRequestVars && preRequestVars.length) { + if (preRequestVars?.length) { const varsRuntime = new VarsRuntime(); varsRuntime.runPreRequestVars( preRequestVars, @@ -64,7 +65,7 @@ const runSingleRequest = async function ( get(collectionRoot, 'request.script.req'), get(bruJson, 'request.script.req') ]).join(os.EOL); - if (requestScriptFile && requestScriptFile.length) { + if (requestScriptFile?.length) { const scriptRuntime = new ScriptRuntime(); await scriptRuntime.runRequestScript( decomment(requestScriptFile), @@ -87,36 +88,56 @@ const runSingleRequest = async function ( if (insecure) { httpsAgentRequestFields['rejectUnauthorized'] = false; } else { - const cacertArray = [options['cacert'], process.env.SSL_CERT_FILE, process.env.NODE_EXTRA_CA_CERTS]; - const cacert = cacertArray.find((el) => el); - if (cacert && cacert.length > 1) { + const caCertArray = [options['cacert'], process.env.SSL_CERT_FILE, process.env.NODE_EXTRA_CA_CERTS]; + const caCert = caCertArray.find((el) => el); + if (caCert && caCert.length > 1) { try { - caCrt = fs.readFileSync(cacert); - httpsAgentRequestFields['ca'] = caCrt; + httpsAgentRequestFields['ca'] = fs.readFileSync(caCert); } catch (err) { - console.log('Error reading CA cert file:' + cacert, err); + console.log('Error reading CA cert file:' + caCert, err); + } + } + } + + const interpolationOptions = { + envVars: envVariables, + collectionVariables, + processEnvVars + }; + + // client certificate config + const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []); + for (let clientCert of clientCertConfig) { + const domain = interpolateString(clientCert.domain, interpolationOptions); + const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions); + const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions); + if (domain && certFilePath && keyFilePath) { + const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); + + if (request.url.match(hostRegex)) { + try { + httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath); + httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath); + } catch (err) { + console.log('Error reading cert/key file', err); + } + httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions); + break; } } } // set proxy if enabled const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); - if (proxyEnabled) { - let proxyUri; - const interpolationOptions = { - envVars: envVariables, - collectionVariables, - processEnvVars - }; - + const shouldProxy = shouldUseProxy(request.url, get(brunoConfig, 'proxy.noProxy', '')); + if (proxyEnabled && shouldProxy) { const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); const socksEnabled = proxyProtocol.includes('socks'); - interpolateString; - + let proxyUri; if (proxyAuthEnabled) { const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions); const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions); @@ -128,16 +149,13 @@ const runSingleRequest = async function ( if (socksEnabled) { const socksProxyAgent = new SocksProxyAgent(proxyUri); - request.httpsAgent = socksProxyAgent; - request.httpAgent = socksProxyAgent; } else { request.httpsAgent = new HttpsProxyAgent( proxyUri, Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined ); - request.httpAgent = new HttpProxyAgent(proxyUri); } } else if (Object.keys(httpsAgentRequestFields).length > 0) { @@ -163,7 +181,7 @@ const runSingleRequest = async function ( responseTime = response.headers.get('request-duration'); response.headers.delete('request-duration'); } catch (err) { - if (err && err.response) { + if (err?.response) { response = err.response; // Prevents the duration on leaking to the actual result @@ -199,7 +217,7 @@ const runSingleRequest = async function ( // run post-response vars const postResponseVars = get(bruJson, 'request.vars.res'); - if (postResponseVars && postResponseVars.length) { + if (postResponseVars?.length) { const varsRuntime = new VarsRuntime(); varsRuntime.runPostResponseVars( postResponseVars, @@ -217,7 +235,7 @@ const runSingleRequest = async function ( get(collectionRoot, 'request.script.res'), get(bruJson, 'request.script.res') ]).join(os.EOL); - if (responseScriptFile && responseScriptFile.length) { + if (responseScriptFile?.length) { const scriptRuntime = new ScriptRuntime(); await scriptRuntime.runResponseScript( decomment(responseScriptFile), @@ -275,7 +293,7 @@ const runSingleRequest = async function ( testResults = get(result, 'results', []); } - if (testResults && testResults.length) { + if (testResults?.length) { each(testResults, (testResult) => { if (testResult.status === 'pass') { console.log(chalk.green(` ✓ `) + chalk.dim(testResult.description)); diff --git a/packages/bruno-cli/src/utils/axios-instance.js b/packages/bruno-cli/src/utils/axios-instance.js index 286ffc0f5..225156484 100644 --- a/packages/bruno-cli/src/utils/axios-instance.js +++ b/packages/bruno-cli/src/utils/axios-instance.js @@ -4,10 +4,10 @@ const axios = require('axios'); * Function that configures axios with timing interceptors * Important to note here that the timings are not completely accurate. * @see https://github.com/axios/axios/issues/695 - * @returns {import('axios').AxiosStatic} + * @returns {axios.AxiosInstance} */ function makeAxiosInstance() { - /** @type {import('axios').AxiosStatic} */ + /** @type {axios.AxiosInstance} */ const instance = axios.create(); instance.interceptors.request.use((config) => { @@ -26,9 +26,7 @@ function makeAxiosInstance() { if (error.response) { const end = Date.now(); const start = error.config.headers['request-start-time']; - if (error.response) { - error.response.headers['request-duration'] = end - start; - } + error.response.headers['request-duration'] = end - start; } return Promise.reject(error); } diff --git a/packages/bruno-cli/src/utils/proxy-util.js b/packages/bruno-cli/src/utils/proxy-util.js new file mode 100644 index 000000000..b3e885a66 --- /dev/null +++ b/packages/bruno-cli/src/utils/proxy-util.js @@ -0,0 +1,65 @@ +const parseUrl = require('url').parse; + +const DEFAULT_PORTS = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443 +}; +/** + * check for proxy bypass, Copied form 'proxy-from-env' + */ +const shouldUseProxy = (url, proxyByPass) => { + if (proxyByPass === '*') { + return false; // Never proxy if wildcard is set. + } + + if (!proxyByPass) { + return true; // use proxy if enabled + } + + const parsedUrl = typeof url === 'string' ? parseUrl(url) : url || {}; + let proto = parsedUrl.protocol; + let hostname = parsedUrl.host; + let port = parsedUrl.port; + if (typeof hostname !== 'string' || !hostname || typeof proto !== 'string') { + return false; // Don't proxy URLs without a valid scheme or host. + } + + proto = proto.split(':', 1)[0]; + // Stripping ports in this way instead of using parsedUrl.hostname to make + // sure that the brackets around IPv6 addresses are kept. + hostname = hostname.replace(/:\d*$/, ''); + port = parseInt(port) || DEFAULT_PORTS[proto] || 0; + + return proxyByPass.split(/[,;\s]/).every(function (dontProxyFor) { + if (!dontProxyFor) { + return true; // Skip zero-length hosts. + } + + const parsedProxy = dontProxyFor.match(/^(.+):(\d+)$/); + let parsedProxyHostname = parsedProxy ? parsedProxy[1] : dontProxyFor; + const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2]) : 0; + if (parsedProxyPort && parsedProxyPort !== port) { + return true; // Skip if ports don't match. + } + + if (!/^[.*]/.test(parsedProxyHostname)) { + // No wildcards, so stop proxying if there is an exact match. + return hostname !== parsedProxyHostname; + } + + if (parsedProxyHostname.charAt(0) === '*') { + // Remove leading wildcard. + parsedProxyHostname = parsedProxyHostname.slice(1); + } + // Stop proxying if the hostname ends with the no_proxy host. + return !hostname.endsWith(parsedProxyHostname); + }); +}; + +module.exports = { + shouldUseProxy +}; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 2199122dc..a0b509abc 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -75,7 +75,7 @@ app.on('ready', async () => { }); // register all ipc handlers - registerNetworkIpc(mainWindow, watcher, lastOpenedCollections); + registerNetworkIpc(mainWindow); registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections); registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections); }); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index b4acd5eb0..e3f896763 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -18,6 +18,7 @@ const { stringifyJson } = require('../utils/common'); const { openCollectionDialog } = require('../app/collections'); const { generateUidBasedOnHash } = require('../utils/common'); const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids'); +const { setPreferences } = require('../store/preferences'); const EnvironmentSecretsStore = require('../store/env-secrets'); const environmentSecretsStore = new EnvironmentSecretsStore(); @@ -32,9 +33,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection // browse directory ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => { try { - const dirPath = await browseDirectory(mainWindow); - - return dirPath; + return await browseDirectory(mainWindow); } catch (error) { return Promise.reject(error); } @@ -67,8 +66,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid); - - return; } catch (error) { return Promise.reject(error); } @@ -93,8 +90,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection collectionPathname, newName }); - - return; } catch (error) { return Promise.reject(error); } @@ -311,7 +306,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection fs.unlinkSync(pathname); } else { - return Promise.reject(error); + return Promise.reject(); } } catch (error) { return Promise.reject(error); diff --git a/packages/bruno-electron/src/ipc/network/axios-instance.js b/packages/bruno-electron/src/ipc/network/axios-instance.js index f4810becd..225156484 100644 --- a/packages/bruno-electron/src/ipc/network/axios-instance.js +++ b/packages/bruno-electron/src/ipc/network/axios-instance.js @@ -4,10 +4,10 @@ const axios = require('axios'); * Function that configures axios with timing interceptors * Important to note here that the timings are not completely accurate. * @see https://github.com/axios/axios/issues/695 - * @returns {import('axios').AxiosStatic} + * @returns {axios.AxiosInstance} */ function makeAxiosInstance() { - /** @type {import('axios').AxiosStatic} */ + /** @type {axios.AxiosInstance} */ const instance = axios.create(); instance.interceptors.request.use((config) => { diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 89be40f36..adbf623e5 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -16,7 +16,7 @@ const { uuid } = require('../../utils/common'); const interpolateVars = require('./interpolate-vars'); const { interpolateString } = require('./interpolate-string'); const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper'); -const { getPreferences } = require('../../store/preferences'); +const { preferences } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); const { getBrunoConfig } = require('../../store/bruno-config'); const { HttpsProxyAgent } = require('https-proxy-agent'); @@ -24,6 +24,7 @@ const { HttpProxyAgent } = require('http-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); const { makeAxiosInstance } = require('./axios-instance'); const { addAwsV4Interceptor, resolveCredentials } = require('./awsv4auth-helper'); +const { shouldUseProxy } = require('../../utils/proxy-util'); // override the default escape function to prevent escaping Mustache.escape = function (value) { @@ -83,6 +84,96 @@ const getSize = (data) => { return 0; }; +const configureRequest = async (collectionUid, request, envVars, collectionVariables, processEnvVars) => { + const httpsAgentRequestFields = {}; + if (!preferences.isTlsVerification()) { + httpsAgentRequestFields['rejectUnauthorized'] = false; + } + + const brunoConfig = getBrunoConfig(collectionUid); + const interpolationOptions = { + envVars, + collectionVariables, + processEnvVars + }; + + // client certificate config + const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []); + for (let clientCert of clientCertConfig) { + const domain = interpolateString(clientCert.domain, interpolationOptions); + const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions); + const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions); + if (domain && certFilePath && keyFilePath) { + const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); + + if (request.url.match(hostRegex)) { + try { + httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath); + httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath); + } catch (err) { + console.log('Error reading cert/key file', err); + } + httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions); + break; + } + } + } + + // proxy configuration + let proxyConfig = get(brunoConfig, 'proxy', {}); + let proxyEnabled = get(proxyConfig, 'enabled', 'disabled'); + if (proxyEnabled === 'global') { + proxyConfig = preferences.getProxyConfig(); + proxyEnabled = get(proxyConfig, 'enabled', false); + } + const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'noProxy', '')); + if ((proxyEnabled === true || proxyEnabled === 'enabled') && shouldProxy) { + const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions); + const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions); + const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions); + const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false); + const socksEnabled = proxyProtocol.includes('socks'); + + let proxyUri; + if (proxyAuthEnabled) { + const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions); + const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions); + + proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`; + } else { + proxyUri = `${proxyProtocol}://${proxyHostname}:${proxyPort}`; + } + + if (socksEnabled) { + const socksProxyAgent = new SocksProxyAgent(proxyUri); + request.httpsAgent = socksProxyAgent; + request.httpAgent = socksProxyAgent; + } else { + request.httpsAgent = new HttpsProxyAgent( + proxyUri, + Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined + ); + request.httpAgent = new HttpProxyAgent(proxyUri); + } + } else if (Object.keys(httpsAgentRequestFields).length > 0) { + request.httpsAgent = new https.Agent({ + ...httpsAgentRequestFields + }); + } + + const axiosInstance = makeAxiosInstance(); + + if (request.awsv4config) { + request.awsv4config = await resolveCredentials(request); + addAwsV4Interceptor(axiosInstance, request); + delete request.awsv4config; + } + + request.timeout = preferences.getTimeout(); + + return axiosInstance; +}; + const registerNetworkIpc = (mainWindow) => { // handler for sending http request ipcMain.handle('send-http-request', async (event, item, collection, environment, collectionVariables) => { @@ -134,7 +225,7 @@ const registerNetworkIpc = (mainWindow) => { // run pre-request vars const preRequestVars = get(request, 'vars.req', []); - if (preRequestVars && preRequestVars.length) { + if (preRequestVars?.length) { const varsRuntime = new VarsRuntime(); const result = varsRuntime.runPreRequestVars( preRequestVars, @@ -155,15 +246,11 @@ const registerNetworkIpc = (mainWindow) => { } } - const preferences = getPreferences(); - const timeout = get(preferences, 'request.timeout', 0); - request.timeout = timeout; - // run pre-request script const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join( os.EOL ); - if (requestScript && requestScript.length) { + if (requestScript?.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runRequestScript( decomment(requestScript), @@ -209,96 +296,20 @@ const registerNetworkIpc = (mainWindow) => { cancelTokenUid }); - const sslVerification = get(preferences, 'request.sslVerification', true); - const httpsAgentRequestFields = {}; - if (!sslVerification) { - httpsAgentRequestFields['rejectUnauthorized'] = false; - } - - const brunoConfig = getBrunoConfig(collectionUid); - const interpolationOptions = { + const axiosInstance = await configureRequest( + collectionUid, + request, envVars, collectionVariables, processEnvVars - }; - - // client certificate config - const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []); - - for (clientCert of clientCertConfig) { - const domain = interpolateString(clientCert.domain, interpolationOptions); - const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions); - const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions); - if (domain && certFilePath && keyFilePath) { - const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); - - if (request.url.match(hostRegex)) { - try { - httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath); - httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath); - } catch (err) { - console.log('Error reading cert/key file', err); - } - httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions); - break; - } - } - } - - // proxy configuration - const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); - if (proxyEnabled) { - let proxyUri; - - const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); - const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); - const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); - const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); - const socksEnabled = proxyProtocol.includes('socks'); - - if (proxyAuthEnabled) { - const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions); - const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions); - - proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`; - } else { - proxyUri = `${proxyProtocol}://${proxyHostname}:${proxyPort}`; - } - - if (socksEnabled) { - const socksProxyAgent = new SocksProxyAgent(proxyUri); - - request.httpsAgent = socksProxyAgent; - - request.httpAgent = socksProxyAgent; - } else { - request.httpsAgent = new HttpsProxyAgent( - proxyUri, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined - ); - - request.httpAgent = new HttpProxyAgent(proxyUri); - } - } else if (Object.keys(httpsAgentRequestFields).length > 0) { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); - } - - const axiosInstance = makeAxiosInstance(); - - if (request.awsv4config) { - request.awsv4config = await resolveCredentials(request); - addAwsV4Interceptor(axiosInstance, request); - delete request.awsv4config; - } + ); /** @type {import('axios').AxiosResponse} */ const response = await axiosInstance(request); // run post-response vars const postResponseVars = get(request, 'vars.res', []); - if (postResponseVars && postResponseVars.length) { + if (postResponseVars?.length) { const varsRuntime = new VarsRuntime(); const result = varsRuntime.runPostResponseVars( postResponseVars, @@ -324,7 +335,7 @@ const registerNetworkIpc = (mainWindow) => { const responseScript = compact([get(collectionRoot, 'request.script.res'), get(request, 'script.res')]).join( os.EOL ); - if (responseScript && responseScript.length) { + if (responseScript?.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runResponseScript( decomment(responseScript), @@ -427,7 +438,7 @@ const registerNetworkIpc = (mainWindow) => { return Promise.reject(error); } - if (error && error.response) { + if (error?.response) { // run assertions const assertions = get(request, 'assertions'); if (assertions) { @@ -519,12 +530,9 @@ const registerNetworkIpc = (mainWindow) => { const collectionRoot = get(collection, 'root', {}); const preparedRequest = prepareGqlIntrospectionRequest(endpoint, envVars, request, collectionRoot); - const preferences = getPreferences(); - const timeout = get(preferences, 'request.timeout', 0); - request.timeout = timeout; - const sslVerification = get(preferences, 'request.sslVerification', true); + request.timeout = preferences.getTimeout(); - if (!sslVerification) { + if (!preferences.isTlsVerification()) { request.httpsAgent = new https.Agent({ rejectUnauthorized: false }); @@ -658,20 +666,11 @@ const registerNetworkIpc = (mainWindow) => { } } - const preferences = getPreferences(); - const timeout = get(preferences, 'request.timeout', 0); - request.timeout = timeout; - const sslVerification = get(preferences, 'request.sslVerification', true); - const httpsAgentRequestFields = {}; - if (!sslVerification) { - httpsAgentRequestFields['rejectUnauthorized'] = false; - } - // run pre-request script const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join( os.EOL ); - if (requestScript && requestScript.length) { + if (requestScript?.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runRequestScript( decomment(requestScript), @@ -708,92 +707,22 @@ const registerNetworkIpc = (mainWindow) => { ...eventData }); - const interpolationOptions = { + const axiosInstance = await configureRequest( + collectionUid, + request, envVars, collectionVariables, processEnvVars - }; - const brunoConfig = getBrunoConfig(collectionUid); + ); - // client certificate config - const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []); - - for (clientCert of clientCertConfig) { - const domain = interpolateString(clientCert.domain, interpolationOptions); - const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions); - const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions); - if (domain && certFilePath && keyFilePath) { - const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); - - if (request.url.match(hostRegex)) { - try { - httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath); - httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath); - } catch (err) { - console.log('Error reading cert/key file', err); - } - httpsAgentRequestFields['passphrase'] = interpolateString( - clientCert.passphrase, - interpolationOptions - ); - break; - } - } - } - - // proxy configuration - const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); - if (proxyEnabled) { - let proxyUri; - const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); - const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); - const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); - const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); - const socksEnabled = proxyProtocol.includes('socks'); - - if (proxyAuthEnabled) { - const proxyAuthUsername = interpolateString( - get(brunoConfig, 'proxy.auth.username'), - interpolationOptions - ); - - const proxyAuthPassword = interpolateString( - get(brunoConfig, 'proxy.auth.password'), - interpolationOptions - ); - - proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`; - } else { - proxyUri = `${proxyProtocol}://${proxyHostname}:${proxyPort}`; - } - - if (socksEnabled) { - const socksProxyAgent = new SocksProxyAgent(proxyUri); - - request.httpsAgent = socksProxyAgent; - request.httpAgent = socksProxyAgent; - } else { - request.httpsAgent = new HttpsProxyAgent( - proxyUri, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined - ); - - request.httpAgent = new HttpProxyAgent(proxyUri); - } - } else if (Object.keys(httpsAgentRequestFields).length > 0) { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); - } - - // send request timeStart = Date.now(); - const response = await axios(request); + /** @type {import('axios').AxiosResponse} */ + const response = await axiosInstance(request); timeEnd = Date.now(); // run post-response vars const postResponseVars = get(request, 'vars.res', []); - if (postResponseVars && postResponseVars.length) { + if (postResponseVars?.length) { const varsRuntime = new VarsRuntime(); const result = varsRuntime.runPostResponseVars( postResponseVars, @@ -913,7 +842,7 @@ const registerNetworkIpc = (mainWindow) => { duration = timeEnd - timeStart; } - if (error && error.response) { + if (error?.response) { responseReceived = { status: error.response.status, statusText: error.response.statusText, diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js index f93ec5e6f..602de92b9 100644 --- a/packages/bruno-electron/src/ipc/preferences.js +++ b/packages/bruno-electron/src/ipc/preferences.js @@ -1,9 +1,64 @@ const { ipcMain } = require('electron'); -const { getPreferences, savePreferences } = require('../store/preferences'); +const { getPreferences, savePreferences, getPath } = require('../store/preferences'); const { isDirectory } = require('../utils/filesystem'); const { openCollection } = require('../app/collections'); +const stores = require('../store'); +const chokidar = require('chokidar'); const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => { + const change = async (pathname, store) => { + if (store === stores.PREFERENCES) { + mainWindow.webContents.send('main:load-preferences', getPreferences()); + } + }; + + class StoreWatcher { + constructor() { + this.watchers = {}; + } + + addWatcher(watchPath, store) { + console.log(`watcher add: ${watchPath} for store ${store}`); + + if (this.watchers[watchPath]) { + this.watchers[watchPath].close(); + } + + const self = this; + setTimeout(() => { + const watcher = chokidar.watch(watchPath, { + ignoreInitial: false, + usePolling: false, + persistent: true, + ignorePermissionErrors: true, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 10 + }, + depth: 20 + }); + + watcher.on('change', (pathname) => change(pathname, store)); + + self.watchers[watchPath] = watcher; + }, 100); + } + + hasWatcher(watchPath) { + return this.watchers[watchPath]; + } + + removeWatcher(watchPath) { + if (this.watchers[watchPath]) { + this.watchers[watchPath].close(); + this.watchers[watchPath] = null; + } + } + } + + const storeWatcher = new StoreWatcher(); + storeWatcher.addWatcher(getPath(), stores.PREFERENCES); + ipcMain.handle('renderer:ready', async (event) => { // load preferences const preferences = getPreferences(); @@ -15,7 +70,7 @@ const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => { if (lastOpened && lastOpened.length) { for (let collectionPath of lastOpened) { if (isDirectory(collectionPath)) { - openCollection(mainWindow, watcher, collectionPath, { + await openCollection(mainWindow, watcher, collectionPath, { dontSendDisplayErrors: true }); } diff --git a/packages/bruno-electron/src/store/index.js b/packages/bruno-electron/src/store/index.js new file mode 100644 index 000000000..40d62b0c5 --- /dev/null +++ b/packages/bruno-electron/src/store/index.js @@ -0,0 +1,7 @@ +const PREFERENCES = 'PREFERENCES'; + +const stores = { + PREFERENCES +}; + +module.exports = stores; diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index 0a94f4cb0..88d411e68 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -1,5 +1,12 @@ const Yup = require('yup'); const Store = require('electron-store'); +const { get } = require('lodash'); + +/** + * The preferences are stored in the electron store 'preferences.json'. + * The electron process uses this module to get the preferences. + * + */ const defaultPreferences = { request: { @@ -8,6 +15,18 @@ const defaultPreferences = { }, font: { codeFont: 'default' + }, + proxy: { + enabled: false, + protocol: 'http', + hostnameHttp: '', + portHttp: '', + auth: { + enabled: false, + username: '', + password: '' + }, + noProxy: '' } }; @@ -18,6 +37,18 @@ const preferencesSchema = Yup.object().shape({ }), font: Yup.object().shape({ codeFont: Yup.string().nullable() + }), + proxy: Yup.object({ + enabled: Yup.boolean(), + protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']), + hostname: Yup.string().max(1024), + port: Yup.number().min(1).max(65535), + auth: Yup.object({ + enabled: Yup.boolean(), + username: Yup.string().max(1024), + password: Yup.string().max(1024) + }).optional(), + noProxy: Yup.string().optional().max(1024) }) }); @@ -29,6 +60,10 @@ class PreferencesStore { }); } + getPath() { + return this.store.path; + } + getPreferences() { return { ...defaultPreferences, @@ -61,7 +96,27 @@ const savePreferences = async (newPreferences) => { }); }; +const getPath = () => { + return preferencesStore.getPath(); +}; + +const preferences = { + isTlsVerification: () => { + return get(getPreferences(), 'request.sslVerification', true); + }, + + getTimeout: () => { + return get(getPreferences(), 'request.timeout', 0); + }, + + getProxyConfig: () => { + return get(getPreferences(), 'proxy', {}); + } +}; + module.exports = { getPreferences, - savePreferences + savePreferences, + getPath, + preferences }; diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js new file mode 100644 index 000000000..9b3f8d172 --- /dev/null +++ b/packages/bruno-electron/src/utils/proxy-util.js @@ -0,0 +1,64 @@ +const parseUrl = require('url').parse; + +const DEFAULT_PORTS = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443 +}; +/** + * check for proxy bypass, copied form 'proxy-from-env' + */ +const shouldUseProxy = (url, proxyByPass) => { + if (proxyByPass === '*') { + return false; // Never proxy if wildcard is set. + } + + if (!proxyByPass) { + return true; // use proxy if enabled + } + + const parsedUrl = typeof url === 'string' ? parseUrl(url) : url || {}; + let proto = parsedUrl.protocol; + let hostname = parsedUrl.host; + let port = parsedUrl.port; + if (typeof hostname !== 'string' || !hostname || typeof proto !== 'string') { + return false; // Don't proxy URLs without a valid scheme or host. + } + + proto = proto.split(':', 1)[0]; + // Stripping ports in this way instead of using parsedUrl.hostname to make + // sure that the brackets around IPv6 addresses are kept. + hostname = hostname.replace(/:\d*$/, ''); + port = parseInt(port) || DEFAULT_PORTS[proto] || 0; + + return proxyByPass.split(/[,;\s]/).every(function (dontProxyFor) { + if (!dontProxyFor) { + return true; // Skip zero-length hosts. + } + const parsedProxy = dontProxyFor.match(/^(.+):(\d+)$/); + let parsedProxyHostname = parsedProxy ? parsedProxy[1] : dontProxyFor; + const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2]) : 0; + if (parsedProxyPort && parsedProxyPort !== port) { + return true; // Skip if ports don't match. + } + + if (!/^[.*]/.test(parsedProxyHostname)) { + // No wildcards, so stop proxying if there is an exact match. + return hostname !== parsedProxyHostname; + } + + if (parsedProxyHostname.charAt(0) === '*') { + // Remove leading wildcard. + parsedProxyHostname = parsedProxyHostname.slice(1); + } + // Stop proxying if the hostname ends with the no_proxy host. + return !hostname.endsWith(parsedProxyHostname); + }); +}; + +module.exports = { + shouldUseProxy +}; diff --git a/packages/bruno-electron/tests/utils/proxy-util.spec.js b/packages/bruno-electron/tests/utils/proxy-util.spec.js new file mode 100644 index 000000000..ac1d5a940 --- /dev/null +++ b/packages/bruno-electron/tests/utils/proxy-util.spec.js @@ -0,0 +1,50 @@ +const { shouldUseProxy } = require('../../src/utils/proxy-util'); + +test('no proxy necessary - star', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = '*'; + + expect(shouldUseProxy(url, noProxy)).toEqual(false); +}); + +test('no proxy necessary - no noProxy bypass', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = ''; + + expect(shouldUseProxy(url, noProxy)).toEqual(true); +}); + +test('no proxy necessary - wildcard match', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = '*example.org'; + + expect(shouldUseProxy(url, noProxy)).toEqual(false); +}); + +test('no proxy necessary - direct proxy', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = 'wwww.example.org'; + + expect(shouldUseProxy(url, noProxy)).toEqual(false); +}); + +test('no proxy necessary - multiple proxy', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = 'www.example.com,wwww.example.org'; + + expect(shouldUseProxy(url, noProxy)).toEqual(false); +}); + +test('proxy necessary - no proxy match multiple', () => { + const url = 'https://wwww.example.test/test'; + const noProxy = 'www.example.com,wwww.example.org'; + + expect(shouldUseProxy(url, noProxy)).toEqual(true); +}); + +test('proxy necessary - no proxy match', () => { + const url = 'https://wwww.example.test/test'; + const noProxy = 'www.example.com'; + + expect(shouldUseProxy(url, noProxy)).toEqual(true); +}); diff --git a/tests/home.spec.js b/tests/home.spec.js index 6c10905fb..0ab8a5652 100644 --- a/tests/home.spec.js +++ b/tests/home.spec.js @@ -1,6 +1,6 @@ const { test, expect } = require('@playwright/test'); const { HomePage } = require('../tests/pages/home.page'); -import * as faker from './utils/data-faker'; +const { faker } = require('./utils/data-faker'); test.describe('bruno e2e test', () => { let homePage;