feat: add certs and proxy config for bruno-cli oauth2 requests (#6423)

This commit is contained in:
lohit
2026-01-20 16:12:48 +00:00
committed by GitHub
parent 7689288763
commit 7e258003d5
12 changed files with 762 additions and 25 deletions

3
package-lock.json generated
View File

@@ -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"

View File

@@ -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",

View File

@@ -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
};

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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';

View File

@@ -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.');
});
});

View File

@@ -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<string, any>): unknown => {
const seen = new WeakSet<object>();
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<string, unknown>, walk);
}
return value;
} finally {
seen.delete(value as object);
}
}
return value;
};
return walk(obj);
};
export default interpolate;

View File

@@ -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"

View File

@@ -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<string | null> => {
export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore, verbose: string, axiosInstance?: AxiosInstance): Promise<string | null> => {
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}`);
}

View File

@@ -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';

View File

@@ -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<string, number> = {
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<any> | 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<any> {
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<any> | 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<AgentResult> => {
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 };