diff --git a/package-lock.json b/package-lock.json index bdf071a92..b3035fead 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35245,7 +35245,10 @@ "debug": "^4.4.3", "google-protobuf": "^4.0.0", "grpc-js-reflection-client": "^1.3.0", + "http-proxy-agent": "~7.0.2", + "https-proxy-agent": "~7.0.6", "is-ip": "^5.0.1", + "socks-proxy-agent": "~8.0.5", "system-ca": "^2.0.1", "tough-cookie": "^6.0.0", "ws": "^8.18.3" diff --git a/package.json b/package.json index b15e02d9d..46fc41de5 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "build:electron:rpm": "./scripts/build-electron.sh rpm", "build:electron:snap": "./scripts/build-electron.sh snap", "watch:common": "npm run watch --workspace=packages/bruno-common", + "watch:requests": "npm run watch --workspace=packages/bruno-requests", "test:codegen": "node playwright/codegen.ts", "test:e2e": "playwright test --project=default", "test:e2e:ssl": "playwright test --project=ssl", diff --git a/packages/bruno-cli/src/runner/interpolate-string.js b/packages/bruno-cli/src/runner/interpolate-string.js index e210be339..20c395d71 100644 --- a/packages/bruno-cli/src/runner/interpolate-string.js +++ b/packages/bruno-cli/src/runner/interpolate-string.js @@ -1,13 +1,19 @@ const { forOwn, cloneDeep } = require('lodash'); -const { interpolate } = require('@usebruno/common'); - -const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) => { - if (!str || !str.length || typeof str !== 'string') { - return str; - } +const { interpolate, interpolateObject: interpolateObjectCommon } = require('@usebruno/common'); +const buildCombinedVars = ({ + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars +}) => { processEnvVars = processEnvVars || {}; runtimeVariables = runtimeVariables || {}; + collectionVariables = collectionVariables || {}; + folderVariables = folderVariables || {}; + requestVariables = requestVariables || {}; // we clone envVars because we don't want to modify the original object envVars = envVars ? cloneDeep(envVars) : {}; @@ -25,8 +31,11 @@ const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) = }); // runtimeVariables take precedence over envVars - const combinedVars = { + return { + ...collectionVariables, ...envVars, + ...folderVariables, + ...requestVariables, ...runtimeVariables, process: { env: { @@ -34,10 +43,26 @@ const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) = } } }; +}; +const interpolateString = (str, interpolationOptions) => { + if (!str || !str.length || typeof str !== 'string') { + return str; + } + + const combinedVars = buildCombinedVars(interpolationOptions); return interpolate(str, combinedVars); }; -module.exports = { - interpolateString +/** + * recursively interpolating all string values in a object + */ +const interpolateObject = (obj, interpolationOptions) => { + const combinedVars = buildCombinedVars(interpolationOptions); + return interpolateObjectCommon(obj, combinedVars); +}; + +module.exports = { + interpolateString, + interpolateObject }; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 9c3c5fb9e..5fb8de3a7 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -5,7 +5,7 @@ const fs = require('fs'); const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash'); const prepareRequest = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); -const { interpolateString } = require('./interpolate-string'); +const { interpolateString, interpolateObject } = require('./interpolate-string'); const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js'); const { stripExtension } = require('../utils/filesystem'); const { getOptions } = require('../utils/bru'); @@ -21,7 +21,7 @@ const { getCookieStringForUrl, saveCookies } = require('../utils/cookies'); const { createFormData } = require('../utils/form-data'); const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const { NtlmClient } = require('axios-ntlm'); -const { addDigestInterceptor } = require('@usebruno/requests'); +const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests'); const { getCACertificates, transformProxyConfig } = require('@usebruno/requests'); const { getOAuth2Token } = require('../utils/oauth2'); const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils; @@ -460,7 +460,54 @@ const runSingleRequest = async function ( // Handle OAuth2 authentication if (request.oauth2) { try { - const token = await getOAuth2Token(request.oauth2); + // Prepare interpolation options with all available variables + const oauth2InterpolationOptions = { + envVars: envVariables, + runtimeVariables, + processEnvVars, + collectionVariables: request.collectionVariables || {}, + folderVariables: request.folderVariables || {}, + requestVariables: request.requestVariables || {} + }; + + const accessTokenUrl = request.oauth2.accessTokenUrl ? interpolateString(request.oauth2.accessTokenUrl, oauth2InterpolationOptions) : undefined; + const refreshTokenUrl = request.oauth2.refreshTokenUrl ? interpolateString(request.oauth2.refreshTokenUrl, oauth2InterpolationOptions) : undefined; + const oauth2RequestUrl = accessTokenUrl || refreshTokenUrl; + + let token; + if (oauth2RequestUrl) { + const tlsOptions = { + noproxy: options.noproxy, + shouldVerifyTls: !insecure, + shouldUseCustomCaCertificate: !!options['cacert'], + customCaCertificateFilePath: options['cacert'], + shouldKeepDefaultCaCertificates: !options['ignoreTruststore'] + }; + + const clientCertificates = get(brunoConfig, 'clientCertificates'); + const proxyConfig = get(brunoConfig, 'proxy'); + const interpolatedClientCertificates = clientCertificates ? interpolateObject(clientCertificates, oauth2InterpolationOptions) : undefined; + const interpolatedProxyConfig = proxyConfig ? interpolateObject(proxyConfig, oauth2InterpolationOptions) : undefined; + + const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({ + requestUrl: oauth2RequestUrl, + collectionPath, + options: tlsOptions, + clientCertificates: interpolatedClientCertificates, + collectionLevelProxy: interpolatedProxyConfig, + systemProxyConfig: getSystemProxyEnvVariables() + }); + + const oauth2AxiosInstance = makeAxiosInstanceForOauth2({ + requestMaxRedirects: requestMaxRedirects, + disableCookies: options.disableCookies, + httpAgent: oauth2HttpAgent, + httpsAgent: oauth2HttpsAgent + }); + + token = await getOAuth2Token(request.oauth2, oauth2AxiosInstance); + } + if (token) { const { tokenPlacement = 'header', tokenHeaderPrefix = '', tokenQueryKey = 'access_token' } = request.oauth2; diff --git a/packages/bruno-cli/src/utils/oauth2.js b/packages/bruno-cli/src/utils/oauth2.js index ece6d1407..8b344e8c1 100644 --- a/packages/bruno-cli/src/utils/oauth2.js +++ b/packages/bruno-cli/src/utils/oauth2.js @@ -21,10 +21,10 @@ const getFormattedOauth2Credentials = () => { return credentialsVariables; }; -const getOAuth2Token = (oauth2Config) => { +const getOAuth2Token = (oauth2Config, axiosInstance) => { let options = getOptions(); let verbose = options?.verbose; - return _getOAuth2Token(oauth2Config, tokenStore, verbose); + return _getOAuth2Token(oauth2Config, tokenStore, verbose, axiosInstance); }; module.exports = { diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts index 8ef73549d..3becb2973 100644 --- a/packages/bruno-common/src/index.ts +++ b/packages/bruno-common/src/index.ts @@ -1,5 +1,5 @@ export { mockDataFunctions, timeBasedDynamicVars } from './utils/faker-functions'; -export { default as interpolate } from './interpolate'; +export { default as interpolate, interpolateObject } from './interpolate'; export { default as isRequestTagsIncluded } from './tags'; export * as utils from './utils'; diff --git a/packages/bruno-common/src/interpolate/index.spec.ts b/packages/bruno-common/src/interpolate/index.spec.ts index dac68d3ff..05206ee60 100644 --- a/packages/bruno-common/src/interpolate/index.spec.ts +++ b/packages/bruno-common/src/interpolate/index.spec.ts @@ -1,4 +1,4 @@ -import interpolate from './index'; +import interpolate, { interpolateObject } from './index'; import moment from 'moment'; const BRUNO_BIRTH_DATE = new Date('2019-08-08'); @@ -678,3 +678,182 @@ describe('interpolate - moment() handling', () => { expect(result).toBe('Date is {"now":"2025-04-17T15:33:41.117Z"}'); }); }); + +describe('interpolateObject', () => { + it('should interpolate strings in a flat object', () => { + const obj = { + url: '{{baseUrl}}/api/users', + name: '{{userName}}' + }; + const variables = { baseUrl: 'https://api.example.com', userName: 'Bruno' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + url: 'https://api.example.com/api/users', + name: 'Bruno' + }); + }); + + it('should interpolate strings in nested objects', () => { + const obj = { + request: { + url: '{{baseUrl}}/api', + headers: { + Authorization: 'Bearer {{token}}' + } + } + }; + const variables = { baseUrl: 'https://api.example.com', token: 'abc123' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + request: { + url: 'https://api.example.com/api', + headers: { + Authorization: 'Bearer abc123' + } + } + }); + }); + + it('should interpolate strings in arrays', () => { + const obj = { + urls: ['{{baseUrl}}/one', '{{baseUrl}}/two'] + }; + const variables = { baseUrl: 'https://api.example.com' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + urls: ['https://api.example.com/one', 'https://api.example.com/two'] + }); + }); + + it('should preserve non-string values', () => { + const obj = { + name: '{{name}}', + age: 5, + active: true, + data: null + }; + const variables = { name: 'Bruno' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + name: 'Bruno', + age: 5, + active: true, + data: null + }); + }); + + it('should return null and undefined as-is', () => { + expect(interpolateObject(null, {})).toBeNull(); + expect(interpolateObject(undefined, {})).toBeUndefined(); + }); + + it('should throw on circular references', () => { + const obj: any = { a: 1 }; + obj.self = obj; + + expect(() => interpolateObject(obj, {})).toThrow('Circular reference detected during interpolation.'); + }); + + it('should handle shared object references without throwing false positives', () => { + const shared = { value: '{{sharedValue}}' }; + const obj = { + x: shared, + y: shared + }; + const variables = { sharedValue: 'test' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + x: { value: 'test' }, + y: { value: 'test' } + }); + }); + + it('should handle shared object references in arrays', () => { + const shared = { id: '{{id}}' }; + const obj = { + items: [shared, shared, shared] + }; + const variables = { id: '123' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + items: [{ id: '123' }, { id: '123' }, { id: '123' }] + }); + }); + + it('should handle shared object references in nested structures', () => { + const shared = { name: '{{name}}' }; + const obj = { + user: shared, + profile: { + user: shared, + metadata: { + user: shared + } + } + }; + const variables = { name: 'Bruno' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + user: { name: 'Bruno' }, + profile: { + user: { name: 'Bruno' }, + metadata: { + user: { name: 'Bruno' } + } + } + }); + }); + + it('should handle shared array references', () => { + const shared = ['{{item1}}', '{{item2}}']; + const obj = { + list1: shared, + list2: shared + }; + const variables = { item1: 'a', item2: 'b' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + list1: ['a', 'b'], + list2: ['a', 'b'] + }); + }); + + it('should still detect actual circular references', () => { + const obj: any = { + a: { value: '{{val}}' }, + b: { value: '{{val}}' } + }; + obj.a.circular = obj.a; // Circular reference + + expect(() => interpolateObject(obj, { val: 'test' })).toThrow('Circular reference detected during interpolation.'); + }); + + it('should handle deeply nested circular references', () => { + const obj: any = { + level1: { + level2: { + level3: {} + } + } + }; + obj.level1.level2.level3.circular = obj.level1; + + expect(() => interpolateObject(obj, {})).toThrow('Circular reference detected during interpolation.'); + }); +}); diff --git a/packages/bruno-common/src/interpolate/index.ts b/packages/bruno-common/src/interpolate/index.ts index 288f06c3b..7ecc550ea 100644 --- a/packages/bruno-common/src/interpolate/index.ts +++ b/packages/bruno-common/src/interpolate/index.ts @@ -12,7 +12,7 @@ */ import { mockDataFunctions } from '../utils/faker-functions'; -import { get, isPlainObject } from 'lodash-es'; +import { get, isPlainObject, mapValues } from 'lodash-es'; // regex to match {{$keyword}} const MOCK_PATTERN = /\{\{\$(\w+)\}\}/g; @@ -129,4 +129,33 @@ const replace = ( return resultStr; }; +export const interpolateObject = (obj: unknown, variables: Record): unknown => { + const seen = new WeakSet(); + const walk = (value: unknown): unknown => { + if (value == null) return value; + if (typeof value === 'string') { + return interpolate(value, variables); + } + if (typeof value === 'object') { + if (seen.has(value as object)) { + throw new Error('Circular reference detected during interpolation.'); + } + seen.add(value as object); + try { + if (Array.isArray(value)) { + return value.map(walk); + } + if (isPlainObject(value)) { + return mapValues(value as Record, walk); + } + return value; + } finally { + seen.delete(value as object); + } + } + return value; + }; + return walk(obj); +}; + export default interpolate; diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json index 76d9749f8..e91f79a08 100644 --- a/packages/bruno-requests/package.json +++ b/packages/bruno-requests/package.json @@ -28,7 +28,10 @@ "debug": "^4.4.3", "google-protobuf": "^4.0.0", "grpc-js-reflection-client": "^1.3.0", + "http-proxy-agent": "~7.0.2", + "https-proxy-agent": "~7.0.6", "is-ip": "^5.0.1", + "socks-proxy-agent": "~8.0.5", "system-ca": "^2.0.1", "tough-cookie": "^6.0.0", "ws": "^8.18.3" diff --git a/packages/bruno-requests/src/auth/oauth2-helper.ts b/packages/bruno-requests/src/auth/oauth2-helper.ts index 98ec99bd8..d398694ce 100644 --- a/packages/bruno-requests/src/auth/oauth2-helper.ts +++ b/packages/bruno-requests/src/auth/oauth2-helper.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestConfig, ResponseType } from 'axios'; +import axios, { AxiosInstance, AxiosRequestConfig, ResponseType } from 'axios'; import qs from 'qs'; import debug from 'debug'; @@ -107,7 +107,7 @@ const safeParseJSONBuffer = (data: any) => { /** * Fetches an OAuth2 token using client credentials grant */ -const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => { +const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config, axiosInstance?: AxiosInstance) => { const { accessTokenUrl, clientId, @@ -167,7 +167,8 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => { debug('oauth2')(JSON.stringify(requestConfig, null, 2)); try { - const response = await axios(requestConfig); + const httpClient = axiosInstance || axios; + const response = await httpClient(requestConfig); const parsedData = safeParseJSONBuffer(response.data); if (parsedData && typeof parsedData === 'object') { @@ -197,7 +198,7 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => { /** * Fetches an OAuth2 token using password grant */ -const fetchTokenPassword = async (oauth2Config: OAuth2Config) => { +const fetchTokenPassword = async (oauth2Config: OAuth2Config, axiosInstance?: AxiosInstance) => { const { accessTokenUrl, clientId, @@ -269,7 +270,8 @@ const fetchTokenPassword = async (oauth2Config: OAuth2Config) => { debug('oauth2')(JSON.stringify(requestConfig, null, 2)); try { - const response = await axios(requestConfig); + const httpClient = axiosInstance || axios; + const response = await httpClient(requestConfig); const parsedData = safeParseJSONBuffer(response.data); if (parsedData && typeof parsedData === 'object') { @@ -313,7 +315,7 @@ const isTokenExpired = (credentials: any): boolean => { /** * Manages OAuth2 token retrieval and storage */ -export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore, verbose: string): Promise => { +export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore, verbose: string, axiosInstance?: AxiosInstance): Promise => { const { grantType, accessTokenUrl, @@ -367,9 +369,9 @@ export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: Tok let tokenResponse; if (grantType === 'client_credentials') { - tokenResponse = await fetchTokenClientCredentials(oauth2Config); + tokenResponse = await fetchTokenClientCredentials(oauth2Config, axiosInstance); } else if (grantType === 'password') { - tokenResponse = await fetchTokenPassword(oauth2Config); + tokenResponse = await fetchTokenPassword(oauth2Config, axiosInstance); } else { throw new Error(`Unsupported grant type: ${grantType}`); } diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index 7fe1838a1..1da7b035a 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -7,5 +7,8 @@ export { getCACertificates } from './utils/ca-cert'; export { transformProxyConfig } from './utils/proxy-util'; export { default as createVaultClient, VaultError } from './utils/node-vault'; export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node-vault'; +export { getHttpHttpsAgents } from './utils/http-https-agents'; export * as scripting from './scripting'; + +export { makeAxiosInstance } from './network/axios-instance'; diff --git a/packages/bruno-requests/src/utils/http-https-agents.ts b/packages/bruno-requests/src/utils/http-https-agents.ts new file mode 100644 index 000000000..a34bb8ea0 --- /dev/null +++ b/packages/bruno-requests/src/utils/http-https-agents.ts @@ -0,0 +1,445 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import https from 'node:https'; +import type { Agent as HttpAgent } from 'node:http'; +import type { Agent as HttpsAgent } from 'node:https'; +import { parse as parseUrl, type Url } from 'url'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { SocksProxyAgent } from 'socks-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { isEmpty, get, isUndefined, isNull } from 'lodash'; +import { getCACertificates } from './ca-cert'; +import { transformProxyConfig } from './proxy-util'; + +const DEFAULT_PORTS: Record = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443 +}; + +type ProxyMode = 'on' | 'off' | 'system'; + +type ProxyAuth = { + enabled: boolean; + username?: string; + password?: string; +}; + +type ProxyConfig = { + enabled?: boolean | 'global'; + protocol?: string; + hostname?: string; + port?: number | null; + auth?: ProxyAuth; + bypassProxy?: string; + mode?: ProxyMode; +}; + +type SystemProxyConfig = { + http_proxy?: string; + https_proxy?: string; + no_proxy?: string; +}; + +type ClientCertificate = { + domain?: string; + type?: 'cert' | 'pfx'; + certFilePath?: string; + keyFilePath?: string; + pfxFilePath?: string; + passphrase?: string; +}; + +type CACertificatesCount = { + system: number; + root: number; + custom: number; + extra: number; +}; + +type CertsConfig = { + caCertificatesCount?: CACertificatesCount; + ca?: string | string[]; + cert?: Buffer; + key?: Buffer; + pfx?: Buffer; + passphrase?: string; +}; + +type HttpsAgentRequestFields = { + keepAlive?: boolean; + rejectUnauthorized?: boolean; + caCertificatesCount?: CACertificatesCount; + ca?: string | string[]; +}; + +type TlsOptions = HttpsAgentRequestFields & CertsConfig & { + secureProtocol?: string; + minVersion?: string; + ALPNProtocols?: string[]; +}; + +type AgentResult = { + httpAgent?: HttpAgent; + httpsAgent?: HttpsAgent | HttpsProxyAgent | SocksProxyAgent; +}; + +type ConfigOptions = { + noproxy: boolean; + shouldVerifyTls: boolean; + shouldUseCustomCaCertificate: boolean; + customCaCertificateFilePath?: string; + shouldKeepDefaultCaCertificates: boolean; +}; + +type GetCertsAndProxyConfigParams = { + requestUrl?: string; + collectionPath: string; + options: ConfigOptions; + clientCertificates?: { + certs?: ClientCertificate[]; + }; + collectionLevelProxy?: ProxyConfig; + systemProxyConfig?: SystemProxyConfig; +}; + +type GetCertsAndProxyConfigResult = { + proxyMode: ProxyMode; + proxyConfig: ProxyConfig; + certsConfig: CertsConfig; +}; + +type CreateAgentsParams = { + requestUrl?: string; + proxyMode: ProxyMode; + proxyConfig: ProxyConfig; + certsConfig: CertsConfig; + httpsAgentRequestFields: HttpsAgentRequestFields; + systemProxyConfig?: SystemProxyConfig; +}; + +type GetHttpHttpsAgentsParams = { + requestUrl?: string; + collectionPath: string; + options: ConfigOptions; + clientCertificates?: { + certs?: ClientCertificate[]; + }; + collectionLevelProxy?: ProxyConfig; + systemProxyConfig?: SystemProxyConfig; +}; + +/** + * check for proxy bypass, copied from 'proxy-from-env' + */ +const shouldUseProxy = (url: string | undefined, proxyBypass: string | undefined): boolean => { + if (proxyBypass === '*') { + return false; // Never proxy if wildcard is set. + } + + // use proxy if no proxyBypass is set + if (!proxyBypass || typeof proxyBypass !== 'string' || isEmpty(proxyBypass.trim())) { + return true; + } + + const parsedUrl: Url | {} = typeof url === 'string' ? parseUrl(url) : (url ? (url as unknown as Url) : {}); + const urlObj = parsedUrl as Url; + let proto = urlObj.protocol; + let hostname = urlObj.host; + let port: string | null = urlObj.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*$/, ''); + const portNum = parseInt(port || '', 10) || 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], 10) : 0; + if (parsedProxyPort && parsedProxyPort !== portNum) { + 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); + }); +}; + +/** + * Patched version of HttpsProxyAgent to get around a bug that ignores options + * such as ca and rejectUnauthorized when upgrading the proxied socket to TLS: + * https://github.com/TooTallNate/proxy-agents/issues/194 + */ +class PatchedHttpsProxyAgent extends HttpsProxyAgent { + private constructorOpts: any; + + constructor(proxy: string, opts: any) { + super(proxy, opts); + this.constructorOpts = opts; + } + + async connect(req: any, opts: any) { + const combinedOpts = { ...this.constructorOpts, ...opts }; + return super.connect(req, combinedOpts); + } +} + +const getCertsAndProxyConfig = ({ + requestUrl, + collectionPath, + options, + clientCertificates, + collectionLevelProxy, + systemProxyConfig +}: GetCertsAndProxyConfigParams): GetCertsAndProxyConfigResult => { + const certsConfig: CertsConfig = {}; + + const caCertFilePath = options.shouldUseCustomCaCertificate && options.customCaCertificateFilePath ? options.customCaCertificateFilePath : undefined; + const caCertificatesData = getCACertificates({ + caCertFilePath, + shouldKeepDefaultCerts: options.shouldKeepDefaultCaCertificates + }); + + const caCertificates = caCertificatesData.caCertificates; + const caCertificatesCount = caCertificatesData.caCertificatesCount; + + // configure HTTPS agent with aggregated CA certificates + certsConfig.caCertificatesCount = caCertificatesCount; + certsConfig.ca = caCertificates || []; + + // client certificate config + const clientCertConfig = get(clientCertificates, 'certs', []) as ClientCertificate[]; + + for (const clientCert of clientCertConfig) { + const domain = clientCert?.domain; + const type = clientCert?.type || 'cert'; + if (domain) { + const hostRegex = '^(https:\\/\\/|grpc:\\/\\/|grpcs:\\/\\/)?' + domain.replace(/\./g, '\\.').replace(/\*/g, '.*'); + if (requestUrl && requestUrl.match(hostRegex)) { + if (type === 'cert') { + try { + let certFilePath = clientCert?.certFilePath; + if (!certFilePath) { + throw new Error('certFilePath is required for cert type'); + } + certFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collectionPath, certFilePath); + let keyFilePath = clientCert?.keyFilePath; + if (!keyFilePath) { + throw new Error('keyFilePath is required for cert type'); + } + keyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collectionPath, keyFilePath); + + certsConfig.cert = fs.readFileSync(certFilePath); + certsConfig.key = fs.readFileSync(keyFilePath); + } catch (err: any) { + console.error('Error reading cert/key file', err); + throw new Error(`Error reading cert/key file: ${err.message}`); + } + } else if (type === 'pfx') { + try { + let pfxFilePath = clientCert?.pfxFilePath; + if (!pfxFilePath) { + throw new Error('pfxFilePath is required for pfx type'); + } + pfxFilePath = path.isAbsolute(pfxFilePath) ? pfxFilePath : path.join(collectionPath, pfxFilePath); + certsConfig.pfx = fs.readFileSync(pfxFilePath); + } catch (err: any) { + console.error('Error reading pfx file', err); + throw new Error(`Error reading pfx file: ${err.message}`); + } + } + certsConfig.passphrase = clientCert.passphrase; + break; + } + } + } + + /** + * Proxy configuration + * + * Preferences proxyMode has three possible values: on, off, system + * Collection proxyMode has three possible values: true, false, global + * + * When collection proxyMode is true, it overrides the app-level proxy settings + * When collection proxyMode is false, it ignores the app-level proxy settings + * When collection proxyMode is global, it uses the app-level proxy settings + * + * Below logic calculates the proxyMode and proxyConfig to be used for the request + */ + let proxyMode: ProxyMode = 'off'; + let proxyConfig: ProxyConfig = {}; + + const collectionProxyConfig = transformProxyConfig(collectionLevelProxy || {}) as ProxyConfig; + const collectionProxyDisabled = get(collectionProxyConfig, 'disabled', false); + const collectionProxyInherit = get(collectionProxyConfig, 'inherit', true); + const collectionProxyConfigData = get(collectionProxyConfig, 'config', {}); + + if (options.noproxy || collectionProxyDisabled) { + // If noproxy flag is set or collection proxy is disabled, don't use any proxy + proxyMode = 'off'; + } else if (!collectionProxyDisabled && !collectionProxyInherit) { + // Use collection-specific proxy + proxyConfig = collectionProxyConfigData; + proxyMode = 'on'; + } else if (!collectionProxyDisabled && collectionProxyInherit) { + // Inherit from system proxy + const { http_proxy, https_proxy } = systemProxyConfig || {}; + if (http_proxy?.length || https_proxy?.length) { + proxyMode = 'system'; + } + // else: no system proxy available, proxyMode stays 'off' + } + // else: collection proxy is disabled, proxyMode stays 'off' + + return { proxyMode, proxyConfig, certsConfig }; +}; + +function createAgents({ + requestUrl, + proxyMode, + proxyConfig, + systemProxyConfig, + certsConfig, + httpsAgentRequestFields +}: CreateAgentsParams): AgentResult { + // Ensure TLS options are properly set + const tlsOptions: TlsOptions = { + ...httpsAgentRequestFields, + ...certsConfig, + // Enable all secure protocols by default + secureProtocol: undefined, + // Allow Node.js to choose the protocol + minVersion: 'TLSv1', + rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true + }; + + let httpAgent: HttpAgent | undefined; + let httpsAgent: HttpsAgent | HttpsProxyAgent | SocksProxyAgent | undefined; + + if (proxyMode === 'on') { + const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', '')); + if (shouldProxy) { + const proxyProtocol = get(proxyConfig, 'protocol'); + const proxyHostname = get(proxyConfig, 'hostname'); + const proxyPort = get(proxyConfig, 'port'); + const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false); + const socksEnabled = proxyProtocol && proxyProtocol.includes('socks'); + + if (!proxyProtocol || !proxyHostname) { + throw new Error('Proxy protocol and hostname are required when proxy is enabled'); + } + + const uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`; + let proxyUri: string; + if (proxyAuthEnabled) { + const proxyAuthUsername = encodeURIComponent(get(proxyConfig, 'auth.username', '')); + const proxyAuthPassword = encodeURIComponent(get(proxyConfig, 'auth.password', '')); + proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`; + } else { + proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; + } + + if (socksEnabled) { + httpAgent = new SocksProxyAgent(proxyUri); + httpsAgent = new SocksProxyAgent(proxyUri, tlsOptions as any); + } else { + httpAgent = new HttpProxyAgent(proxyUri); + httpsAgent = new PatchedHttpsProxyAgent(proxyUri, tlsOptions); + } + } else { + // If proxy should not be used, set default HTTPS agent + httpsAgent = new https.Agent(tlsOptions as any); + } + } else if (proxyMode === 'system') { + const http_proxy = get(systemProxyConfig, 'http_proxy'); + const https_proxy = get(systemProxyConfig, 'https_proxy'); + const no_proxy = get(systemProxyConfig, 'no_proxy'); + const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || ''); + if (shouldUseSystemProxy) { + try { + if (http_proxy?.length) { + new URL(http_proxy); + httpAgent = new HttpProxyAgent(http_proxy); + } + } catch (error) { + throw new Error('Invalid system http_proxy'); + } + try { + if (https_proxy?.length) { + new URL(https_proxy); + httpsAgent = new PatchedHttpsProxyAgent(https_proxy, tlsOptions as any); + } else { + httpsAgent = new https.Agent(tlsOptions as any); + } + } catch (error) { + throw new Error('Invalid system https_proxy'); + } + } else { + httpsAgent = new https.Agent(tlsOptions as any); + } + } else { + httpsAgent = new https.Agent(tlsOptions as any); + } + + return { httpAgent, httpsAgent }; +} + +const getHttpHttpsAgents = async ({ + requestUrl, + collectionPath, + clientCertificates, + collectionLevelProxy, + systemProxyConfig, + options +}: GetHttpHttpsAgentsParams): Promise => { + const { proxyMode, proxyConfig, certsConfig } = getCertsAndProxyConfig({ + requestUrl, + collectionPath, + clientCertificates, + collectionLevelProxy, + systemProxyConfig, + options + }); + + /** + * @see https://github.com/usebruno/bruno/issues/211 set keepAlive to true, this should fix socket hang up errors + * @see https://github.com/nodejs/node/pull/43522 keepAlive was changed to true globally on Node v19+ + */ + const httpsAgentRequestFields: HttpsAgentRequestFields = { keepAlive: true }; + if (!options.shouldVerifyTls) { + httpsAgentRequestFields.rejectUnauthorized = false; + } + + const { httpAgent, httpsAgent } = createAgents({ + requestUrl, + proxyMode, + proxyConfig, + systemProxyConfig, + certsConfig, + httpsAgentRequestFields + }); + + return { httpAgent, httpsAgent }; +}; + +export { getHttpHttpsAgents };