From 0a3448dcee7ef7ff478cd770b0c212d5b6a98697 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 23 Jun 2026 16:35:16 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Harden=20User=20Provided?= =?UTF-8?q?=20Endpoint=20URL=20Protection=20(#13919)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/src/endpoints/anthropic/llm.spec.ts | 77 +++++++++++++++--- packages/api/src/endpoints/anthropic/llm.ts | 57 ++++++++++++- .../api/src/endpoints/config/models.spec.ts | 41 +++++++++- packages/api/src/endpoints/config/models.ts | 52 +++++++----- .../src/endpoints/custom/initialize.spec.ts | 21 +++++ .../api/src/endpoints/custom/initialize.ts | 9 +++ packages/api/src/endpoints/models.spec.ts | 79 +++++++++++++++++++ packages/api/src/endpoints/models.ts | 58 ++++++++++++-- .../api/src/endpoints/openai/config.spec.ts | 37 +++++++++ packages/api/src/endpoints/openai/config.ts | 56 ++++++++++++- .../src/endpoints/openai/initialize.spec.ts | 51 ++++++++++++ .../api/src/endpoints/openai/initialize.ts | 17 +++- packages/api/src/types/anthropic.ts | 6 +- packages/api/src/types/openai.ts | 2 + packages/api/src/utils/generators.ts | 28 ++++++- 15 files changed, 539 insertions(+), 52 deletions(-) diff --git a/packages/api/src/endpoints/anthropic/llm.spec.ts b/packages/api/src/endpoints/anthropic/llm.spec.ts index 58f466f5e0..062d906294 100644 --- a/packages/api/src/endpoints/anthropic/llm.spec.ts +++ b/packages/api/src/endpoints/anthropic/llm.spec.ts @@ -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; + 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', () => { diff --git a/packages/api/src/endpoints/anthropic/llm.ts b/packages/api/src/endpoints/anthropic/llm.ts index 512401d0ba..17dcf73c8e 100644 --- a/packages/api/src/endpoints/anthropic/llm.ts +++ b/packages/api/src/endpoints/anthropic/llm.ts @@ -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, + options: FetchOptions, +): void { + const currentOptions = (clientOptions.fetchOptions ?? {}) as FetchOptions; + clientOptions.fetchOptions = { + ...currentOptions, + ...options, + } as NonNullable['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( diff --git a/packages/api/src/endpoints/config/models.spec.ts b/packages/api/src/endpoints/config/models.spec.ts index 9ff9d29cba..1f748bfcbe 100644 --- a/packages/api/src/endpoints/config/models.spec.ts +++ b/packages/api/src/endpoints/config/models.spec.ts @@ -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, }), ); diff --git a/packages/api/src/endpoints/config/models.ts b/packages/api/src/endpoints/config/models.ts index f1bd80509c..27707e1097 100644 --- a/packages/api/src/endpoints/config/models.ts +++ b/packages/api/src/endpoints/config/models.ts @@ -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; diff --git a/packages/api/src/endpoints/custom/initialize.spec.ts b/packages/api/src/endpoints/custom/initialize.spec.ts index be210b6684..d52d2aea74 100644 --- a/packages/api/src/endpoints/custom/initialize.spec.ts +++ b/packages/api/src/endpoints/custom/initialize.spec.ts @@ -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 () => { diff --git a/packages/api/src/endpoints/custom/initialize.ts b/packages/api/src/endpoints/custom/initialize.ts index b91abc76cd..e25b5e1d34 100644 --- a/packages/api/src/endpoints/custom/initialize.ts +++ b/packages/api/src/endpoints/custom/initialize.ts @@ -135,17 +135,21 @@ function buildAnthropicCustomConfig({ modelOptions, endpointConfig, userProvidesURL, + allowedAddresses, }: { apiKey: string; baseURL: string; modelOptions: AnthropicModelOptions; endpointConfig: Partial; 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 = { 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 { diff --git a/packages/api/src/endpoints/models.spec.ts b/packages/api/src/endpoints/models.spec.ts index c8292c8b10..30e1d7546a 100644 --- a/packages/api/src/endpoints/models.spec.ts +++ b/packages/api/src/endpoints/models.spec.ts @@ -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(); }); diff --git a/packages/api/src/endpoints/models.ts b/packages/api/src/endpoints/models.ts index 239dd4b8d3..55fe926a42 100644 --- a/packages/api/src/endpoints/models.ts +++ b/packages/api/src/endpoints/models.ts @@ -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; + 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 | null; user?: Partial } = {}, + options: { + headers?: Record | null; + ssrfAgents?: SSRFSafeAgents; + user?: Partial; + } = {}, ): Promise { if (!baseURL) { return []; @@ -71,12 +99,18 @@ async function fetchOllamaModels( user: options.user, }); + const requestOptions: AxiosRequestConfig & { + headers: Record; + 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; diff --git a/packages/api/src/endpoints/openai/config.spec.ts b/packages/api/src/endpoints/openai/config.spec.ts index ee49fd8fab..f27c20e050 100644 --- a/packages/api/src/endpoints/openai/config.spec.ts +++ b/packages/api/src/endpoints/openai/config.spec.ts @@ -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' }; diff --git a/packages/api/src/endpoints/openai/config.ts b/packages/api/src/endpoints/openai/config.ts index 521590c782..bc425e1a1f 100644 --- a/packages/api/src/endpoints/openai/config.ts +++ b/packages/api/src/endpoints/openai/config.ts @@ -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; +type FetchOptions = RequestInit & { dispatcher?: Dispatcher }; +type OpenAIConfiguration = NonNullable; 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; } diff --git a/packages/api/src/endpoints/openai/initialize.spec.ts b/packages/api/src/endpoints/openai/initialize.spec.ts index 93111ae52f..980dc8cfe4 100644 --- a/packages/api/src/endpoints/openai/initialize.spec.ts +++ b/packages/api/src/endpoints/openai/initialize.spec.ts @@ -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', () => { diff --git a/packages/api/src/endpoints/openai/initialize.ts b/packages/api/src/endpoints/openai/initialize.ts index b0c76b2aad..9e9418e037 100644 --- a/packages/api/src/endpoints/openai/initialize.ts +++ b/packages/api/src/endpoints/openai/initialize.ts @@ -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({ diff --git a/packages/api/src/types/anthropic.ts b/packages/api/src/types/anthropic.ts index ca19482f3b..9c418e61db 100644 --- a/packages/api/src/types/anthropic.ts +++ b/packages/api/src/types/anthropic.ts @@ -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; /** 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; } diff --git a/packages/api/src/types/openai.ts b/packages/api/src/types/openai.ts index 7007cc447e..e0c7923ed7 100644 --- a/packages/api/src/types/openai.ts +++ b/packages/api/src/types/openai.ts @@ -16,6 +16,8 @@ export interface OpenAIConfigOptions { modelOptions?: OpenAIModelOptions; directEndpoint?: boolean; reverseProxyUrl?: string | null; + baseURLIsUserProvided?: boolean; + allowedAddresses?: string[] | null; defaultQuery?: Record; headers?: Record; proxy?: string | null; diff --git a/packages/api/src/utils/generators.ts b/packages/api/src/utils/generators.ts index 29ececd4c4..2e0416cb89 100644 --- a/packages/api/src/utils/generators.ts +++ b/packages/api/src/utils/generators.ts @@ -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); }; }