mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-28 18:31:24 +00:00
🧭 fix: Harden User Provided Endpoint URL Protection (#13919)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
This commit is contained in:
parent
562bd8ec5f
commit
0a3448dcee
15 changed files with 539 additions and 52 deletions
|
|
@ -29,9 +29,68 @@ describe('getLLMConfig', () => {
|
|||
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher).toBeDefined();
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher.constructor.name).toBe(
|
||||
'ProxyAgent',
|
||||
const dispatcher = result.llmConfig.clientOptions?.fetchOptions?.dispatcher;
|
||||
expect(dispatcher).toBeDefined();
|
||||
expect(dispatcher?.constructor.name).toBe('ProxyAgent');
|
||||
});
|
||||
|
||||
it('should harden user-provided reverse proxy URLs with a connect-time dispatcher and disabled redirects', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {},
|
||||
reverseProxyUrl: 'https://user-provider.example.com',
|
||||
baseURLIsUserProvided: true,
|
||||
allowedAddresses: ['10.0.0.5:443'],
|
||||
});
|
||||
|
||||
const fetchOptions = result.llmConfig.clientOptions?.fetchOptions as
|
||||
| { dispatcher?: unknown; redirect?: string }
|
||||
| undefined;
|
||||
expect(fetchOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
dispatcher: expect.any(Object),
|
||||
redirect: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the SSRF-safe dispatcher when a proxy is configured for a user-provided URL', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {},
|
||||
proxy: 'http://proxy:8080',
|
||||
reverseProxyUrl: 'https://user-provider.example.com',
|
||||
baseURLIsUserProvided: true,
|
||||
});
|
||||
|
||||
const fetchOptions = result.llmConfig.clientOptions?.fetchOptions as
|
||||
| { dispatcher?: { constructor: { name: string } }; redirect?: string }
|
||||
| undefined;
|
||||
expect(fetchOptions?.dispatcher?.constructor.name).toBe('Agent');
|
||||
expect(fetchOptions).toEqual(expect.objectContaining({ redirect: 'error' }));
|
||||
});
|
||||
|
||||
it('should keep SSRF fetch options when clientOptions are dropped for a user-provided URL', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {},
|
||||
reverseProxyUrl: 'https://user-provider.example.com',
|
||||
baseURLIsUserProvided: true,
|
||||
dropParams: ['clientOptions'],
|
||||
});
|
||||
|
||||
const clientOptions = result.llmConfig.clientOptions as
|
||||
| {
|
||||
baseURL?: string;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
fetchOptions?: { dispatcher?: unknown; redirect?: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(result.llmConfig.anthropicApiUrl).toBe('https://user-provider.example.com');
|
||||
expect(clientOptions?.baseURL).toBeUndefined();
|
||||
expect(clientOptions?.defaultHeaders).toBeUndefined();
|
||||
expect(clientOptions?.fetchOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
dispatcher: expect.any(Object),
|
||||
redirect: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -333,10 +392,9 @@ describe('getLLMConfig', () => {
|
|||
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher).toBeDefined();
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher.constructor.name).toBe(
|
||||
'ProxyAgent',
|
||||
);
|
||||
const dispatcher = result.llmConfig.clientOptions?.fetchOptions?.dispatcher;
|
||||
expect(dispatcher).toBeDefined();
|
||||
expect(dispatcher?.constructor.name).toBe('ProxyAgent');
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('baseURL', 'https://reverse-proxy.com');
|
||||
expect(result.llmConfig).toHaveProperty('anthropicApiUrl', 'https://reverse-proxy.com');
|
||||
});
|
||||
|
|
@ -534,9 +592,8 @@ describe('getLLMConfig', () => {
|
|||
},
|
||||
});
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher.constructor.name).toBe(
|
||||
'ProxyAgent',
|
||||
);
|
||||
const dispatcher = result.llmConfig.clientOptions?.fetchOptions?.dispatcher;
|
||||
expect(dispatcher?.constructor.name).toBe('ProxyAgent');
|
||||
});
|
||||
|
||||
it('should handle Anthropic with reverse proxy like initialize.js', () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Agent } from 'undici';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { AnthropicClientOptions } from '@librechat/agents';
|
||||
import {
|
||||
|
|
@ -26,11 +27,44 @@ import {
|
|||
isAnthropicVertexCredentials,
|
||||
getVertexDeploymentName,
|
||||
} from './vertex';
|
||||
import { createSSRFSafeUndiciConnect } from '~/auth';
|
||||
import { getProxyDispatcher } from '~/utils/proxy';
|
||||
import { mergeHeaders } from '~/utils/headers';
|
||||
|
||||
const WEB_SEARCH_BETA = 'web-search-2025-03-05';
|
||||
|
||||
type FetchOptions = { dispatcher?: Dispatcher; redirect?: RequestRedirect };
|
||||
|
||||
function getEffectiveURLPort(baseURL: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(baseURL);
|
||||
if (parsed.port) {
|
||||
return parsed.port;
|
||||
}
|
||||
if (parsed.protocol === 'http:') {
|
||||
return '80';
|
||||
}
|
||||
if (parsed.protocol === 'https:') {
|
||||
return '443';
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeFetchOptions(
|
||||
clientOptions: NonNullable<AnthropicClientOptions['clientOptions']>,
|
||||
options: FetchOptions,
|
||||
): void {
|
||||
const currentOptions = (clientOptions.fetchOptions ?? {}) as FetchOptions;
|
||||
clientOptions.fetchOptions = {
|
||||
...currentOptions,
|
||||
...options,
|
||||
} as NonNullable<AnthropicClientOptions['clientOptions']>['fetchOptions'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses credentials from string or object format
|
||||
* - If a valid JSON string is passed, it parses and returns the object
|
||||
|
|
@ -236,11 +270,13 @@ function getLLMConfig(
|
|||
requestOptions.clientOptions.defaultHeaders = headers;
|
||||
}
|
||||
|
||||
const shouldProtectUserBaseURL =
|
||||
options.baseURLIsUserProvided === true && !!options.reverseProxyUrl;
|
||||
const proxyDispatcher = getProxyDispatcher(options.proxy);
|
||||
if (proxyDispatcher && requestOptions.clientOptions) {
|
||||
requestOptions.clientOptions.fetchOptions = {
|
||||
if (proxyDispatcher && !shouldProtectUserBaseURL && requestOptions.clientOptions) {
|
||||
mergeFetchOptions(requestOptions.clientOptions, {
|
||||
dispatcher: proxyDispatcher,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (options.reverseProxyUrl && requestOptions.clientOptions) {
|
||||
|
|
@ -362,6 +398,21 @@ function getLLMConfig(
|
|||
);
|
||||
}
|
||||
|
||||
if (shouldProtectUserBaseURL) {
|
||||
if (!requestOptions.clientOptions) {
|
||||
requestOptions.clientOptions = {};
|
||||
}
|
||||
mergeFetchOptions(requestOptions.clientOptions, {
|
||||
dispatcher: new Agent({
|
||||
connect: createSSRFSafeUndiciConnect(
|
||||
options.allowedAddresses,
|
||||
getEffectiveURLPort(options.reverseProxyUrl ?? ''),
|
||||
),
|
||||
}),
|
||||
redirect: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tools,
|
||||
llmConfig: removeNullishValues(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { AuthType, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import { SCOPED_TOKEN_CONFIG_KEY_PREFIX } from '../keys';
|
||||
import { createLoadConfigModels } from './models';
|
||||
|
||||
const mockValidateEndpointURL = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mock('~/auth', () => ({
|
||||
validateEndpointURL: (...args: unknown[]) => mockValidateEndpointURL(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => {
|
||||
const original = jest.requireActual('~/utils');
|
||||
|
|
@ -12,6 +15,9 @@ jest.mock('~/utils', () => {
|
|||
};
|
||||
});
|
||||
|
||||
import { SCOPED_TOKEN_CONFIG_KEY_PREFIX } from '../keys';
|
||||
import { createLoadConfigModels } from './models';
|
||||
|
||||
describe('createLoadConfigModels – user-provided baseURL header guard', () => {
|
||||
const fetchModels = jest.fn().mockResolvedValue([]);
|
||||
|
||||
|
|
@ -31,6 +37,7 @@ describe('createLoadConfigModels – user-provided baseURL header guard', () =>
|
|||
|
||||
beforeEach(() => {
|
||||
fetchModels.mockReset().mockResolvedValue([]);
|
||||
mockValidateEndpointURL.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('does NOT forward configured headers when baseURL is user-provided', async () => {
|
||||
|
|
@ -56,10 +63,16 @@ describe('createLoadConfigModels – user-provided baseURL header guard', () =>
|
|||
await loadConfigModels(req);
|
||||
|
||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(mockValidateEndpointURL).toHaveBeenCalledWith(
|
||||
'https://user-controlled.example.com/v1',
|
||||
'TestProxy',
|
||||
undefined,
|
||||
);
|
||||
expect(fetchModels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'TestProxy',
|
||||
baseURL: 'https://user-controlled.example.com/v1',
|
||||
baseURLIsUserProvided: true,
|
||||
headers: undefined,
|
||||
}),
|
||||
);
|
||||
|
|
@ -101,6 +114,29 @@ describe('createLoadConfigModels – user-provided baseURL header guard', () =>
|
|||
);
|
||||
});
|
||||
|
||||
it('does NOT call fetchModels when user-provided baseURL validation fails', async () => {
|
||||
mockValidateEndpointURL.mockRejectedValueOnce(new Error('blocked SSRF target'));
|
||||
|
||||
const loadConfigModels = createLoadConfigModels({
|
||||
getAppConfig: jest.fn().mockResolvedValue(buildAppConfig({})),
|
||||
getUserKeyValues: jest.fn().mockResolvedValue({
|
||||
apiKey: 'sk-user-key',
|
||||
baseURL: 'http://127.0.0.1:11434/v1',
|
||||
}),
|
||||
fetchModels,
|
||||
});
|
||||
|
||||
const req = {
|
||||
user: { id: 'user-1' },
|
||||
config: undefined,
|
||||
} as unknown as ServerRequest;
|
||||
|
||||
const modelsConfig = await loadConfigModels(req);
|
||||
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
expect(modelsConfig.TestProxy).toEqual([]);
|
||||
});
|
||||
|
||||
it('DOES forward configured headers when baseURL is admin-trusted (only apiKey is user-provided)', async () => {
|
||||
const headers = {
|
||||
Authorization: 'Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}',
|
||||
|
|
@ -139,6 +175,7 @@ describe('createLoadConfigModels – user-provided baseURL header guard', () =>
|
|||
expect.objectContaining({
|
||||
name: 'TrustedProxy',
|
||||
baseURL: 'https://admin-trusted.example.com/v1',
|
||||
baseURLIsUserProvided: false,
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { ServerRequest, GetUserKeyValuesFunction, UserKeyValues } from '~/t
|
|||
import type { FetchModelsParams } from '~/endpoints/models';
|
||||
import { fetchModels as defaultFetchModels } from '~/endpoints/models';
|
||||
import { getTokenConfigKey } from '~/endpoints/custom/initialize';
|
||||
import { validateEndpointURL } from '~/auth';
|
||||
import { tokenConfigCache } from '~/cache';
|
||||
import { isUserProvided } from '~/utils';
|
||||
|
||||
|
|
@ -189,6 +190,8 @@ export function createLoadConfigModels(deps: LoadConfigModelsDeps) {
|
|||
name,
|
||||
apiKey: API_KEY,
|
||||
baseURL: BASE_URL,
|
||||
baseURLIsUserProvided: false,
|
||||
allowedAddresses: appConfig.endpoints?.allowedAddresses,
|
||||
user: req.user?.id,
|
||||
userObject: req.user,
|
||||
headers: endpointHeaders,
|
||||
|
|
@ -212,25 +215,36 @@ export function createLoadConfigModels(deps: LoadConfigModelsDeps) {
|
|||
const userFetchKey = `user:${req.user?.id}:${name}`;
|
||||
fetchPromisesMap[userFetchKey] =
|
||||
fetchPromisesMap[userFetchKey] ||
|
||||
fetchModels({
|
||||
name,
|
||||
apiKey: resolvedApiKey,
|
||||
baseURL: resolvedBaseURL,
|
||||
user: req.user?.id,
|
||||
userObject: req.user,
|
||||
// Do not forward header overrides when the base URL is
|
||||
// user-supplied: configured templates such as
|
||||
// {{LIBRECHAT_OPENID_ID_TOKEN}} would otherwise resolve and be
|
||||
// sent to a destination the user controls, leaking the user's
|
||||
// identity token. Header overrides are only safe for endpoints
|
||||
// whose base URL is admin-trusted.
|
||||
headers: baseURLIsUserProvided ? undefined : endpointHeaders,
|
||||
direct: endpoint.directEndpoint,
|
||||
userIdQuery: models.userIdQuery,
|
||||
skipCache: true,
|
||||
/** Fetched with the user's key/URL — always user-scoped */
|
||||
tokenKey: getTokenConfigKey(endpoint, name, req.user?.id ?? '', tenantId),
|
||||
});
|
||||
(async () => {
|
||||
if (baseURLIsUserProvided) {
|
||||
await validateEndpointURL(
|
||||
resolvedBaseURL,
|
||||
name,
|
||||
appConfig.endpoints?.allowedAddresses,
|
||||
);
|
||||
}
|
||||
return fetchModels({
|
||||
name,
|
||||
apiKey: resolvedApiKey,
|
||||
baseURL: resolvedBaseURL,
|
||||
baseURLIsUserProvided,
|
||||
allowedAddresses: appConfig.endpoints?.allowedAddresses,
|
||||
user: req.user?.id,
|
||||
userObject: req.user,
|
||||
// Do not forward header overrides when the base URL is
|
||||
// user-supplied: configured templates such as
|
||||
// {{LIBRECHAT_OPENID_ID_TOKEN}} would otherwise resolve and be
|
||||
// sent to a destination the user controls, leaking the user's
|
||||
// identity token. Header overrides are only safe for endpoints
|
||||
// whose base URL is admin-trusted.
|
||||
headers: baseURLIsUserProvided ? undefined : endpointHeaders,
|
||||
direct: endpoint.directEndpoint,
|
||||
userIdQuery: models.userIdQuery,
|
||||
skipCache: true,
|
||||
/** Fetched with the user's key/URL — always user-scoped */
|
||||
tokenKey: getTokenConfigKey(endpoint, name, req.user?.id ?? '', tenantId),
|
||||
});
|
||||
})();
|
||||
uniqueKeyToEndpointsMap[userFetchKey] = uniqueKeyToEndpointsMap[userFetchKey] || [];
|
||||
uniqueKeyToEndpointsMap[userFetchKey].push(name);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,16 @@ import { AuthType, ErrorTypes } from 'librechat-data-provider';
|
|||
import type { BaseInitializeParams } from '~/types';
|
||||
|
||||
const mockValidateEndpointURL = jest.fn();
|
||||
const mockCreateSSRFSafeUndiciConnect = jest.fn(
|
||||
(_allowedAddresses?: string[] | null, _port?: string | number | null) => ({
|
||||
lookup: jest.fn(),
|
||||
}),
|
||||
);
|
||||
jest.mock('~/auth', () => ({
|
||||
validateEndpointURL: (...args: unknown[]) => mockValidateEndpointURL(...args),
|
||||
createSSRFSafeUndiciConnect: (
|
||||
...args: [allowedAddresses?: string[] | null, port?: string | number | null]
|
||||
) => mockCreateSSRFSafeUndiciConnect(...args),
|
||||
}));
|
||||
|
||||
const mockGetOpenAIConfig = jest.fn().mockReturnValue({
|
||||
|
|
@ -378,6 +386,7 @@ describe('initializeCustom – token-config fetch header forwarding', () => {
|
|||
name: 'openrouter',
|
||||
headers,
|
||||
userObject: params.req.user,
|
||||
baseURLIsUserProvided: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -401,6 +410,7 @@ describe('initializeCustom – token-config fetch header forwarding', () => {
|
|||
apiKey: 'sk-user-key',
|
||||
headers: undefined,
|
||||
userObject: params.req.user,
|
||||
baseURLIsUserProvided: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -641,6 +651,17 @@ describe('initializeCustom – native Anthropic provider', () => {
|
|||
).clientOptions?.defaultHeaders;
|
||||
expect(defaultHeaders?.Authorization).toBeUndefined();
|
||||
expect(defaultHeaders?.['X-User-Email']).toBeUndefined();
|
||||
const fetchOptions = (
|
||||
options.llmConfig as {
|
||||
clientOptions?: { fetchOptions?: { dispatcher?: unknown; redirect?: string } };
|
||||
}
|
||||
).clientOptions?.fetchOptions;
|
||||
expect(fetchOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
dispatcher: expect.any(Object),
|
||||
redirect: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies customParams.paramDefinitions defaults on the native path', async () => {
|
||||
|
|
|
|||
|
|
@ -135,17 +135,21 @@ function buildAnthropicCustomConfig({
|
|||
modelOptions,
|
||||
endpointConfig,
|
||||
userProvidesURL,
|
||||
allowedAddresses,
|
||||
}: {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
modelOptions: AnthropicModelOptions;
|
||||
endpointConfig: Partial<TEndpoint>;
|
||||
userProvidesURL: boolean;
|
||||
allowedAddresses?: string[] | null;
|
||||
}): InitializeResultBase {
|
||||
const result = getAnthropicLLMConfig(apiKey, {
|
||||
modelOptions,
|
||||
proxy: PROXY ?? undefined,
|
||||
reverseProxyUrl: baseURL,
|
||||
baseURLIsUserProvided: userProvidesURL,
|
||||
allowedAddresses,
|
||||
headers: userProvidesURL ? undefined : endpointConfig.headers,
|
||||
addParams: endpointConfig.addParams,
|
||||
dropParams: endpointConfig.dropParams,
|
||||
|
|
@ -277,6 +281,8 @@ export async function initializeCustom({
|
|||
await fetchModels({
|
||||
apiKey,
|
||||
baseURL,
|
||||
baseURLIsUserProvided: userProvidesURL,
|
||||
allowedAddresses: appConfig?.endpoints?.allowedAddresses,
|
||||
name: endpoint,
|
||||
user: userId,
|
||||
tokenKey,
|
||||
|
|
@ -304,6 +310,8 @@ export async function initializeCustom({
|
|||
|
||||
const clientOptions: Record<string, unknown> = {
|
||||
reverseProxyUrl: baseURL ?? null,
|
||||
baseURLIsUserProvided: userProvidesURL,
|
||||
allowedAddresses: appConfig?.endpoints?.allowedAddresses,
|
||||
proxy: PROXY ?? null,
|
||||
...customOptions,
|
||||
};
|
||||
|
|
@ -321,6 +329,7 @@ export async function initializeCustom({
|
|||
modelOptions: modelOptions as AnthropicModelOptions,
|
||||
endpointConfig,
|
||||
userProvidesURL,
|
||||
allowedAddresses: appConfig?.endpoints?.allowedAddresses,
|
||||
});
|
||||
options.endpointTokenConfig = endpointTokenConfig;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,21 @@ import { SCOPED_TOKEN_CONFIG_KEY_PREFIX } from './keys';
|
|||
|
||||
jest.mock('axios');
|
||||
|
||||
const mockValidateEndpointURL = jest.fn().mockResolvedValue(undefined);
|
||||
const mockHttpAgent = { type: 'ssrf-http-agent' };
|
||||
const mockHttpsAgent = { type: 'ssrf-https-agent' };
|
||||
const mockCreateSSRFSafeAgents = jest.fn((_allowedAddresses?: string[] | null) => ({
|
||||
httpAgent: mockHttpAgent,
|
||||
httpsAgent: mockHttpsAgent,
|
||||
}));
|
||||
jest.mock('~/auth', () => ({
|
||||
validateEndpointURL: (
|
||||
...args: [url: string, endpoint: string, allowedAddresses?: string[] | null]
|
||||
) => mockValidateEndpointURL(...args),
|
||||
createSSRFSafeAgents: (...args: [allowedAddresses?: string[] | null]) =>
|
||||
mockCreateSSRFSafeAgents(...args),
|
||||
}));
|
||||
|
||||
const mockCacheGet = jest.fn().mockResolvedValue(undefined);
|
||||
const mockCacheSet = jest.fn().mockResolvedValue(true);
|
||||
const mockTokenConfigGet = jest.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -60,6 +75,8 @@ beforeEach(() => {
|
|||
mockCacheSet.mockReset().mockResolvedValue(true);
|
||||
mockTokenConfigGet.mockReset().mockResolvedValue(undefined);
|
||||
mockTokenConfigSet.mockReset().mockResolvedValue(true);
|
||||
mockValidateEndpointURL.mockReset().mockResolvedValue(undefined);
|
||||
mockCreateSSRFSafeAgents.mockClear();
|
||||
});
|
||||
|
||||
describe('fetchModels', () => {
|
||||
|
|
@ -244,6 +261,68 @@ describe('fetchModels', () => {
|
|||
expect(sentHeaders.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates and hardens user-provided base URL model fetches', async () => {
|
||||
const savedEnv = {
|
||||
PROXY: process.env.PROXY,
|
||||
HTTP_PROXY: process.env.HTTP_PROXY,
|
||||
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||
NO_PROXY: process.env.NO_PROXY,
|
||||
};
|
||||
delete process.env.PROXY;
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
|
||||
try {
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com',
|
||||
baseURLIsUserProvided: true,
|
||||
allowedAddresses: ['10.0.0.5:443'],
|
||||
name: 'TestAPI',
|
||||
});
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockValidateEndpointURL).toHaveBeenCalledWith('https://api.test.com', 'TestAPI', [
|
||||
'10.0.0.5:443',
|
||||
]);
|
||||
expect(mockCreateSSRFSafeAgents).toHaveBeenCalledWith(['10.0.0.5:443']);
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://api.test.com/models',
|
||||
expect.objectContaining({
|
||||
httpAgent: mockHttpAgent,
|
||||
httpsAgent: mockHttpsAgent,
|
||||
maxRedirects: 0,
|
||||
proxy: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a blocked user-provided base URL before any model fetch request', async () => {
|
||||
mockValidateEndpointURL.mockRejectedValueOnce(new Error('blocked SSRF target'));
|
||||
|
||||
await expect(
|
||||
fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'http://127.0.0.1:11434/v1',
|
||||
baseURLIsUserProvided: true,
|
||||
name: 'TestAPI',
|
||||
}),
|
||||
).rejects.toThrow('blocked SSRF target');
|
||||
|
||||
expect(mockedAxios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,8 +21,11 @@ import {
|
|||
applyAxiosProxyConfig,
|
||||
} from '~/utils';
|
||||
import { getModelCacheTokenConfigKey, isScopedTokenConfigKey } from '~/endpoints/keys';
|
||||
import { createSSRFSafeAgents, validateEndpointURL } from '~/auth';
|
||||
import { standardCache, tokenConfigCache } from '~/cache';
|
||||
|
||||
type SSRFSafeAgents = ReturnType<typeof createSSRFSafeAgents>;
|
||||
|
||||
export interface FetchModelsParams {
|
||||
/** User ID for API requests */
|
||||
user?: string;
|
||||
|
|
@ -30,6 +33,10 @@ export interface FetchModelsParams {
|
|||
apiKey: string;
|
||||
/** Base URL for the API */
|
||||
baseURL?: string;
|
||||
/** Whether the base URL came from a user-stored credential */
|
||||
baseURLIsUserProvided?: boolean;
|
||||
/** Admin-approved internal host:port exemptions for user-provided base URLs */
|
||||
allowedAddresses?: string[] | null;
|
||||
/** Endpoint name (defaults to 'openAI') */
|
||||
name?: string;
|
||||
/** Whether directEndpoint was configured */
|
||||
|
|
@ -50,6 +57,23 @@ export interface FetchModelsParams {
|
|||
skipCache?: boolean;
|
||||
}
|
||||
|
||||
function applyUserProvidedBaseURLProtection(
|
||||
options: AxiosRequestConfig,
|
||||
ssrfAgents?: SSRFSafeAgents,
|
||||
): AxiosRequestConfig {
|
||||
if (!ssrfAgents) {
|
||||
return options;
|
||||
}
|
||||
|
||||
options.maxRedirects = 0;
|
||||
|
||||
options.proxy = false;
|
||||
options.httpAgent = ssrfAgents.httpAgent;
|
||||
options.httpsAgent = ssrfAgents.httpsAgent;
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Ollama models from the specified base API path.
|
||||
* @param baseURL - The Ollama server URL
|
||||
|
|
@ -58,7 +82,11 @@ export interface FetchModelsParams {
|
|||
*/
|
||||
async function fetchOllamaModels(
|
||||
baseURL: string,
|
||||
options: { headers?: Record<string, string> | null; user?: Partial<IUser> } = {},
|
||||
options: {
|
||||
headers?: Record<string, string> | null;
|
||||
ssrfAgents?: SSRFSafeAgents;
|
||||
user?: Partial<IUser>;
|
||||
} = {},
|
||||
): Promise<string[]> {
|
||||
if (!baseURL) {
|
||||
return [];
|
||||
|
|
@ -71,12 +99,18 @@ async function fetchOllamaModels(
|
|||
user: options.user,
|
||||
});
|
||||
|
||||
const requestOptions: AxiosRequestConfig & {
|
||||
headers: Record<string, string>;
|
||||
timeout: number;
|
||||
} = {
|
||||
headers: resolvedHeaders,
|
||||
timeout: 5000,
|
||||
};
|
||||
applyUserProvidedBaseURLProtection(requestOptions, options.ssrfAgents);
|
||||
|
||||
const response = await axios.get<{ models: Array<{ name: string }> }>(
|
||||
`${ollamaEndpoint}/api/tags`,
|
||||
{
|
||||
headers: resolvedHeaders,
|
||||
timeout: 5000,
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return response.data.models.map((tag) => tag.name);
|
||||
|
|
@ -120,6 +154,8 @@ export async function fetchModels({
|
|||
user,
|
||||
apiKey,
|
||||
baseURL: _baseURL,
|
||||
baseURLIsUserProvided = false,
|
||||
allowedAddresses,
|
||||
name = EModelEndpoint.openAI,
|
||||
direct = false,
|
||||
azure = false,
|
||||
|
|
@ -141,6 +177,11 @@ export async function fetchModels({
|
|||
return models;
|
||||
}
|
||||
|
||||
const ssrfAgents = baseURLIsUserProvided ? createSSRFSafeAgents(allowedAddresses) : undefined;
|
||||
if (baseURLIsUserProvided && baseURL) {
|
||||
await validateEndpointURL(baseURL, name, allowedAddresses);
|
||||
}
|
||||
|
||||
// The MODEL_QUERIES cache is keyed by baseURL+apiKey only. That's safe
|
||||
// when the response is identical for every caller, but fails when callers
|
||||
// forward header templates that resolve to a user-bound value (e.g.
|
||||
|
|
@ -172,7 +213,11 @@ export async function fetchModels({
|
|||
if (name && name.toLowerCase().startsWith(KnownEndpoints.ollama)) {
|
||||
let ollamaModels: string[] | null = null;
|
||||
try {
|
||||
ollamaModels = await fetchOllamaModels(baseURL ?? '', { headers, user: userObject });
|
||||
ollamaModels = await fetchOllamaModels(baseURL ?? '', {
|
||||
headers,
|
||||
ssrfAgents,
|
||||
user: userObject,
|
||||
});
|
||||
} catch (ollamaError) {
|
||||
logAxiosError({
|
||||
message:
|
||||
|
|
@ -237,6 +282,7 @@ export async function fetchModels({
|
|||
url.searchParams.append('user', user);
|
||||
}
|
||||
applyAxiosProxyConfig(options, url);
|
||||
applyUserProvidedBaseURLProtection(options, ssrfAgents);
|
||||
const res = await axios.get(url.toString(), options);
|
||||
|
||||
const input = res.data;
|
||||
|
|
|
|||
|
|
@ -474,6 +474,43 @@ describe('getOpenAIConfig', () => {
|
|||
expect((result.configOptions?.fetchOptions as RequestInit).dispatcher).toBeDefined();
|
||||
});
|
||||
|
||||
it('should harden user-provided base URLs with a connect-time dispatcher and disabled redirects', () => {
|
||||
const result = getOpenAIConfig(mockApiKey, {
|
||||
reverseProxyUrl: 'https://user-provider.example.com/v1',
|
||||
baseURLIsUserProvided: true,
|
||||
allowedAddresses: ['10.0.0.5:443'],
|
||||
});
|
||||
|
||||
expect(result.configOptions?.baseURL).toBe('https://user-provider.example.com/v1');
|
||||
expect(result.configOptions?.fetchOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
dispatcher: expect.any(Object),
|
||||
redirect: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the SSRF-safe dispatcher when a proxy is configured for a user-provided URL', () => {
|
||||
const result = getOpenAIConfig(mockApiKey, {
|
||||
reverseProxyUrl: 'https://user-provider.example.com/v1',
|
||||
baseURLIsUserProvided: true,
|
||||
proxy: 'http://proxy.example.com:8080',
|
||||
});
|
||||
|
||||
const unproxiedResult = getOpenAIConfig(mockApiKey, {
|
||||
reverseProxyUrl: 'https://user-provider.example.com/v1',
|
||||
baseURLIsUserProvided: true,
|
||||
});
|
||||
|
||||
expect(result.configOptions?.fetchOptions?.dispatcher).toBeDefined();
|
||||
expect(result.configOptions?.fetchOptions?.dispatcher?.constructor.name).toBe(
|
||||
unproxiedResult.configOptions?.fetchOptions?.dispatcher?.constructor.name,
|
||||
);
|
||||
expect(result.configOptions?.fetchOptions).toEqual(
|
||||
expect.objectContaining({ redirect: 'error' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle headers and defaultQuery', () => {
|
||||
const headers = { 'X-Custom-Header': 'value' };
|
||||
const defaultQuery = { customParam: 'value' };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { Agent } from 'undici';
|
||||
import { Providers } from '@librechat/agents';
|
||||
import { KnownEndpoints, EModelEndpoint, ReasoningParameterFormat } from 'librechat-data-provider';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import type * as t from '~/types';
|
||||
import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm';
|
||||
import { createSSRFSafeAgents, createSSRFSafeUndiciConnect } from '~/auth';
|
||||
import { getOpenAILLMConfig, extractDefaultParams } from './llm';
|
||||
import { getGoogleConfig } from '~/endpoints/google/llm';
|
||||
import { transformToOpenAIConfig } from './transform';
|
||||
|
|
@ -11,6 +14,8 @@ import { createFetch } from '~/utils/generators';
|
|||
import { mergeHeaders } from '~/utils/headers';
|
||||
|
||||
type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
type FetchOptions = RequestInit & { dispatcher?: Dispatcher };
|
||||
type OpenAIConfiguration = NonNullable<t.OpenAIConfiguration>;
|
||||
|
||||
const OPENROUTER_DEFAULT_PARAMS = { promptCache: true };
|
||||
|
||||
|
|
@ -51,6 +56,33 @@ function getReasoningFormat({
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function getEffectiveURLPort(baseURL: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(baseURL);
|
||||
if (parsed.port) {
|
||||
return parsed.port;
|
||||
}
|
||||
if (parsed.protocol === 'http:') {
|
||||
return '80';
|
||||
}
|
||||
if (parsed.protocol === 'https:') {
|
||||
return '443';
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeFetchOptions(configOptions: OpenAIConfiguration, options: FetchOptions): void {
|
||||
const currentOptions = (configOptions.fetchOptions ?? {}) as FetchOptions;
|
||||
configOptions.fetchOptions = {
|
||||
...currentOptions,
|
||||
...options,
|
||||
} as OpenAIConfiguration['fetchOptions'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates configuration options for creating a language model (LLM) instance.
|
||||
* @param apiKey - The API key for authentication.
|
||||
|
|
@ -73,6 +105,10 @@ export function getOpenAIConfig(
|
|||
modelOptions = {},
|
||||
reverseProxyUrl: baseURL,
|
||||
} = options;
|
||||
const shouldProtectUserBaseURL = options.baseURLIsUserProvided === true && !!baseURL;
|
||||
const ssrfAgents = shouldProtectUserBaseURL
|
||||
? createSSRFSafeAgents(options.allowedAddresses)
|
||||
: undefined;
|
||||
|
||||
let llmConfig: t.OAIClientOptions;
|
||||
let tools: t.LLMConfigResult['tools'];
|
||||
|
|
@ -188,11 +224,21 @@ export function getOpenAIConfig(
|
|||
configOptions.defaultQuery = defaultQuery;
|
||||
}
|
||||
|
||||
if (shouldProtectUserBaseURL) {
|
||||
mergeFetchOptions(configOptions, {
|
||||
dispatcher: new Agent({
|
||||
connect: createSSRFSafeUndiciConnect(
|
||||
options.allowedAddresses,
|
||||
getEffectiveURLPort(baseURL),
|
||||
),
|
||||
}),
|
||||
redirect: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
const proxyDispatcher = getProxyDispatcher(proxy);
|
||||
if (proxyDispatcher) {
|
||||
configOptions.fetchOptions = {
|
||||
dispatcher: proxyDispatcher,
|
||||
};
|
||||
if (proxyDispatcher && !shouldProtectUserBaseURL) {
|
||||
mergeFetchOptions(configOptions, { dispatcher: proxyDispatcher });
|
||||
}
|
||||
|
||||
if (azure && !isAnthropic) {
|
||||
|
|
@ -229,6 +275,8 @@ export function getOpenAIConfig(
|
|||
configOptions.fetch = createFetch({
|
||||
directEndpoint: directEndpoint,
|
||||
reverseProxyUrl: configOptions?.baseURL,
|
||||
ssrfAgents,
|
||||
redirect: shouldProtectUserBaseURL ? 'error' : undefined,
|
||||
}) as unknown as Fetch;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,15 @@ describe('initializeOpenAI – SSRF guard wiring', () => {
|
|||
EModelEndpoint.openAI,
|
||||
undefined,
|
||||
);
|
||||
expect(mockGetOpenAIConfig).toHaveBeenCalledWith(
|
||||
'sk-test',
|
||||
expect.objectContaining({
|
||||
reverseProxyUrl: 'https://user-proxy.example.com/v1',
|
||||
baseURLIsUserProvided: true,
|
||||
allowedAddresses: undefined,
|
||||
}),
|
||||
EModelEndpoint.openAI,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT call validateEndpointURL when OPENAI_REVERSE_PROXY is a system URL', async () => {
|
||||
|
|
@ -135,6 +144,48 @@ describe('initializeOpenAI – SSRF guard wiring', () => {
|
|||
|
||||
expect(mockGetOpenAIConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not validate a stale user Azure URL when an admin model group baseURL is selected', async () => {
|
||||
const params = createParams({
|
||||
AZURE_API_KEY: 'az-env-key',
|
||||
AZURE_OPENAI_BASEURL: AuthType.USER_PROVIDED,
|
||||
});
|
||||
params.endpoint = EModelEndpoint.azureOpenAI;
|
||||
params.model_parameters = { model: 'gpt-4o' };
|
||||
params.req.config = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
modelGroupMap: {
|
||||
'gpt-4o': { group: 'serverless-group' },
|
||||
},
|
||||
groupMap: {
|
||||
'serverless-group': {
|
||||
apiKey: 'az-admin-key',
|
||||
baseURL: 'https://admin-azure.example.com/openai/deployments/gpt-4o',
|
||||
version: '2024-10-21',
|
||||
serverless: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as BaseInitializeParams['req']['config'];
|
||||
|
||||
try {
|
||||
await initializeOpenAI(params);
|
||||
} finally {
|
||||
(params as unknown as { _restore: () => void })._restore();
|
||||
}
|
||||
|
||||
expect(mockValidateEndpointURL).not.toHaveBeenCalled();
|
||||
expect(mockGetOpenAIConfig).toHaveBeenCalledWith(
|
||||
'az-admin-key',
|
||||
expect.objectContaining({
|
||||
reverseProxyUrl: 'https://admin-azure.example.com/openai/deployments/gpt-4o',
|
||||
baseURLIsUserProvided: false,
|
||||
}),
|
||||
EModelEndpoint.azureOpenAI,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeOpenAI – custom headers', () => {
|
||||
|
|
|
|||
|
|
@ -64,13 +64,11 @@ export async function initializeOpenAI({
|
|||
? userValues?.baseURL
|
||||
: baseURLOptions[endpoint as keyof typeof baseURLOptions];
|
||||
|
||||
if (userProvidesURL && baseURL) {
|
||||
await validateEndpointURL(baseURL, endpoint, appConfig?.endpoints?.allowedAddresses);
|
||||
}
|
||||
|
||||
const clientOptions: OpenAIConfigOptions = {
|
||||
proxy: PROXY ?? undefined,
|
||||
reverseProxyUrl: baseURL || undefined,
|
||||
baseURLIsUserProvided: userProvidesURL,
|
||||
allowedAddresses: appConfig?.endpoints?.allowedAddresses,
|
||||
streaming: true,
|
||||
};
|
||||
|
||||
|
|
@ -105,6 +103,9 @@ export async function initializeOpenAI({
|
|||
isServerless = serverless === true;
|
||||
|
||||
clientOptions.reverseProxyUrl = configBaseURL ?? clientOptions.reverseProxyUrl;
|
||||
if (configBaseURL) {
|
||||
clientOptions.baseURLIsUserProvided = false;
|
||||
}
|
||||
clientOptions.headers = resolveHeaders({
|
||||
headers: { ...headers, ...(clientOptions.headers ?? {}) },
|
||||
user: req.user,
|
||||
|
|
@ -155,6 +156,14 @@ export async function initializeOpenAI({
|
|||
}
|
||||
}
|
||||
|
||||
if (clientOptions.baseURLIsUserProvided && clientOptions.reverseProxyUrl) {
|
||||
await validateEndpointURL(
|
||||
clientOptions.reverseProxyUrl,
|
||||
endpoint,
|
||||
appConfig?.endpoints?.allowedAddresses,
|
||||
);
|
||||
}
|
||||
|
||||
if (userProvidesKey && !apiKey) {
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -80,6 +80,10 @@ export interface AnthropicConfigOptions {
|
|||
proxy?: string | null;
|
||||
/** URL for a reverse proxy, if used */
|
||||
reverseProxyUrl?: string | null;
|
||||
/** Whether the reverse proxy URL came from a user-stored credential */
|
||||
baseURLIsUserProvided?: boolean;
|
||||
/** Admin-approved internal host:port exemptions for user-provided base URLs */
|
||||
allowedAddresses?: string[] | null;
|
||||
/** Default parameters to apply only if fields are undefined */
|
||||
defaultParams?: Record<string, unknown>;
|
||||
/** Additional parameters to add to the configuration */
|
||||
|
|
@ -103,7 +107,7 @@ export interface AnthropicConfigOptions {
|
|||
export type AnthropicLLMConfigResult = LLMConfigResult<
|
||||
AnthropicClientOptions & {
|
||||
clientOptions?: {
|
||||
fetchOptions?: { dispatcher: Dispatcher };
|
||||
fetchOptions?: { dispatcher?: Dispatcher; redirect?: RequestRedirect };
|
||||
};
|
||||
stream?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export interface OpenAIConfigOptions {
|
|||
modelOptions?: OpenAIModelOptions;
|
||||
directEndpoint?: boolean;
|
||||
reverseProxyUrl?: string | null;
|
||||
baseURLIsUserProvided?: boolean;
|
||||
allowedAddresses?: string[] | null;
|
||||
defaultQuery?: Record<string, string | undefined>;
|
||||
headers?: Record<string, string>;
|
||||
proxy?: string | null;
|
||||
|
|
|
|||
|
|
@ -2,22 +2,36 @@ import fetch from 'node-fetch';
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { GraphEvents, sleep } from '@librechat/agents';
|
||||
import type { Response as ServerResponse } from 'express';
|
||||
import type { Agent as HttpsAgent } from 'node:https';
|
||||
import type { Agent as HttpAgent } from 'node:http';
|
||||
import type { URL as NodeURL } from 'node:url';
|
||||
import type { ServerSentEvent } from '~/types';
|
||||
import { sendEvent } from './events';
|
||||
|
||||
type SSRFSafeAgents = {
|
||||
httpAgent: HttpAgent;
|
||||
httpsAgent: HttpsAgent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a function to make HTTP request and logs the process.
|
||||
* @param params
|
||||
* @param params.directEndpoint - Whether to use a direct endpoint.
|
||||
* @param params.reverseProxyUrl - The reverse proxy URL to use for the request.
|
||||
* @param params.ssrfAgents - Optional SSRF-safe agents for user-provided URLs.
|
||||
* @param params.redirect - Optional redirect policy for user-provided URLs.
|
||||
* @returns A promise that resolves to the response of the fetch request.
|
||||
*/
|
||||
export function createFetch({
|
||||
directEndpoint = false,
|
||||
reverseProxyUrl = '',
|
||||
ssrfAgents,
|
||||
redirect,
|
||||
}: {
|
||||
directEndpoint?: boolean;
|
||||
reverseProxyUrl?: string;
|
||||
ssrfAgents?: SSRFSafeAgents;
|
||||
redirect?: fetch.RequestRedirect;
|
||||
}) {
|
||||
/**
|
||||
* Makes an HTTP request and logs the process.
|
||||
|
|
@ -34,10 +48,18 @@ export function createFetch({
|
|||
url = reverseProxyUrl;
|
||||
}
|
||||
logger.debug(`Making request to ${url}`);
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return await fetch(url, init);
|
||||
const requestInit = { ...init };
|
||||
if (ssrfAgents) {
|
||||
requestInit.agent = (parsedURL: NodeURL) =>
|
||||
parsedURL.protocol === 'http:' ? ssrfAgents.httpAgent : ssrfAgents.httpsAgent;
|
||||
}
|
||||
return await fetch(url, init);
|
||||
if (redirect) {
|
||||
requestInit.redirect = redirect;
|
||||
}
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return await fetch(url, requestInit);
|
||||
}
|
||||
return await fetch(url, requestInit);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue