mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-28 07:04:10 +00:00
feat: add certs and proxy config for bruno-cli oauth2 requests (#6423)
This commit is contained in:
3
package-lock.json
generated
3
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
445
packages/bruno-requests/src/utils/http-https-agents.ts
Normal file
445
packages/bruno-requests/src/utils/http-https-agents.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user