🧭 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

This commit is contained in:
Danny Avila 2026-06-23 16:35:16 -04:00 committed by GitHub
parent 562bd8ec5f
commit 0a3448dcee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 539 additions and 52 deletions

View file

@ -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', () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

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

View file

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

View file

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

View file

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