From 606292c5c50507150166316cfbecd17cc820cece Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 23 Jun 2026 15:49:31 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=90=20fix:=20Withhold=20Custom=20Endpo?= =?UTF-8?q?int=20Headers=20for=20User=20URLs=20(#13917)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: withhold custom endpoint headers for user URLs * fix: require user key for user custom URLs * test: type custom endpoint header cases * fix: prompt for keys on user custom URLs --- .../api/src/endpoints/config/models.spec.ts | 36 ++++++ packages/api/src/endpoints/config/models.ts | 3 +- .../api/src/endpoints/custom/config.spec.ts | 43 ++++++- packages/api/src/endpoints/custom/config.ts | 5 +- .../src/endpoints/custom/initialize.spec.ts | 106 ++++++++++++++++++ .../api/src/endpoints/custom/initialize.ts | 17 ++- 6 files changed, 202 insertions(+), 8 deletions(-) diff --git a/packages/api/src/endpoints/config/models.spec.ts b/packages/api/src/endpoints/config/models.spec.ts index 6a3fce8aec..9ff9d29cba 100644 --- a/packages/api/src/endpoints/config/models.spec.ts +++ b/packages/api/src/endpoints/config/models.spec.ts @@ -65,6 +65,42 @@ describe('createLoadConfigModels – user-provided baseURL header guard', () => ); }); + it('uses the user API key when baseURL is user-provided', async () => { + const loadConfigModels = createLoadConfigModels({ + getAppConfig: jest.fn().mockResolvedValue( + buildAppConfig({ + apiKey: 'sk-system-key', + }), + ), + getUserKeyValues: jest.fn().mockResolvedValue({ + apiKey: 'sk-user-key', + baseURL: 'https://user-controlled.example.com/v1', + }), + fetchModels, + }); + + const req = { + user: { id: 'user-1', email: 'user@example.com' }, + config: undefined, + } as unknown as ServerRequest; + + await loadConfigModels(req); + + expect(fetchModels).toHaveBeenCalledTimes(1); + expect(fetchModels).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'TestProxy', + apiKey: 'sk-user-key', + baseURL: 'https://user-controlled.example.com/v1', + }), + ); + expect(fetchModels).not.toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'sk-system-key', + }), + ); + }); + it('DOES forward configured headers when baseURL is admin-trusted (only apiKey is user-provided)', async () => { const headers = { Authorization: 'Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}', diff --git a/packages/api/src/endpoints/config/models.ts b/packages/api/src/endpoints/config/models.ts index 9680795fa4..f1bd80509c 100644 --- a/packages/api/src/endpoints/config/models.ts +++ b/packages/api/src/endpoints/config/models.ts @@ -204,7 +204,8 @@ export function createLoadConfigModels(deps: LoadConfigModelsDeps) { if (models?.fetch && userKeyMap.has(name)) { const userKeyValues = userKeyMap.get(name); - const resolvedApiKey = apiKeyIsUserProvided ? userKeyValues?.apiKey : API_KEY; + const resolvedApiKey = + apiKeyIsUserProvided || baseURLIsUserProvided ? userKeyValues?.apiKey : API_KEY; const resolvedBaseURL = baseURLIsUserProvided ? userKeyValues?.baseURL : BASE_URL; if (resolvedApiKey && resolvedBaseURL) { diff --git a/packages/api/src/endpoints/custom/config.spec.ts b/packages/api/src/endpoints/custom/config.spec.ts index 0b1b1eb20a..b0154b3b41 100644 --- a/packages/api/src/endpoints/custom/config.spec.ts +++ b/packages/api/src/endpoints/custom/config.spec.ts @@ -1,4 +1,4 @@ -import { EModelEndpoint } from 'librechat-data-provider'; +import { AuthType, EModelEndpoint } from 'librechat-data-provider'; import type { TCustomEndpoints } from 'librechat-data-provider'; import { loadCustomEndpointsConfig } from './config'; @@ -42,3 +42,44 @@ describe('loadCustomEndpointsConfig – native provider param set', () => { ); }); }); + +describe('loadCustomEndpointsConfig – user credential prompts', () => { + it('requires a user key when the custom base URL is user-provided', () => { + const config = loadCustomEndpointsConfig([ + { ...baseEndpoint, name: 'User URL', baseURL: AuthType.USER_PROVIDED }, + ] as unknown as TCustomEndpoints); + + expect(config?.['User URL']).toEqual( + expect.objectContaining({ + userProvide: true, + userProvideURL: true, + }), + ); + }); + + it('requires a user key when the custom API key is user-provided', () => { + const config = loadCustomEndpointsConfig([ + { ...baseEndpoint, name: 'User Key', apiKey: AuthType.USER_PROVIDED }, + ] as unknown as TCustomEndpoints); + + expect(config?.['User Key']).toEqual( + expect.objectContaining({ + userProvide: true, + userProvideURL: false, + }), + ); + }); + + it('does not require a user key for admin-trusted credentials and base URL', () => { + const config = loadCustomEndpointsConfig([ + { ...baseEndpoint, name: 'Admin Trusted' }, + ] as unknown as TCustomEndpoints); + + expect(config?.['Admin Trusted']).toEqual( + expect.objectContaining({ + userProvide: false, + userProvideURL: false, + }), + ); + }); +}); diff --git a/packages/api/src/endpoints/custom/config.ts b/packages/api/src/endpoints/custom/config.ts index f8d2de47e5..fdf829ad7c 100644 --- a/packages/api/src/endpoints/custom/config.ts +++ b/packages/api/src/endpoints/custom/config.ts @@ -41,6 +41,7 @@ export function loadCustomEndpointsConfig( const resolvedApiKey = extractEnvVariable(apiKey ?? ''); const resolvedBaseURL = extractEnvVariable(baseURL ?? ''); + const userProvideURL = isUserProvided(resolvedBaseURL); /** * A native `provider` (e.g. anthropic) implies its parameter set. Surface it @@ -57,8 +58,8 @@ export function loadCustomEndpointsConfig( customEndpointsConfig[name] = { type: EModelEndpoint.custom, - userProvide: isUserProvided(resolvedApiKey), - userProvideURL: isUserProvided(resolvedBaseURL), + userProvide: isUserProvided(resolvedApiKey) || userProvideURL, + userProvideURL, customParams: resolvedCustomParams, modelDisplayLabel, iconURL, diff --git a/packages/api/src/endpoints/custom/initialize.spec.ts b/packages/api/src/endpoints/custom/initialize.spec.ts index 544dfdeba5..be210b6684 100644 --- a/packages/api/src/endpoints/custom/initialize.spec.ts +++ b/packages/api/src/endpoints/custom/initialize.spec.ts @@ -42,6 +42,7 @@ function createParams(overrides: { userBaseURL?: string; userApiKey?: string; expiresAt?: string; + headers?: Record; }): BaseInitializeParams { const { apiKey = 'sk-test-key', baseURL = 'https://api.example.com/v1' } = overrides; @@ -49,6 +50,7 @@ function createParams(overrides: { apiKey, baseURL, models: {}, + headers: overrides.headers, }); const db = { @@ -115,6 +117,11 @@ describe('initializeCustom – Agents API user key resolution', () => { name: 'test-custom', }); expect(checkUserKeyExpiry).not.toHaveBeenCalled(); + expect(mockGetOpenAIConfig).toHaveBeenCalledWith( + 'sk-user-key', + expect.any(Object), + 'test-custom', + ); }); it('should still check key expiry when expiresAt is provided (UI flow)', async () => { @@ -161,6 +168,103 @@ describe('initializeCustom – Agents API user key resolution', () => { }); }); +describe('initializeCustom – OpenAI-compatible header forwarding', () => { + const userControlledHeaderCases: Array<{ + headerType: string; + headers: Record; + }> = [ + { + headerType: 'Authorization', + headers: { Authorization: 'Bearer static-gateway-token' }, + }, + { + headerType: 'env-secret', + headers: { 'X-Env-Secret': '${GATEWAY_SECRET}' }, + }, + { + headerType: 'user-placeholder', + headers: { 'X-User-Email': '{{LIBRECHAT_USER_EMAIL}}' }, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('preserves configured headers for admin-trusted base URLs', async () => { + const headers = { + Authorization: 'Bearer static-gateway-token', + 'X-Env-Secret': '${GATEWAY_SECRET}', + 'X-User-Email': '{{LIBRECHAT_USER_EMAIL}}', + }; + const params = createParams({ + apiKey: 'sk-system-key', + baseURL: 'https://gateway.example.com/v1', + headers, + }); + + await initializeCustom(params); + + const clientOptions = mockGetOpenAIConfig.mock.calls[0][1] as { + headers?: Record; + }; + expect(clientOptions.headers).toEqual(headers); + }); + + it('uses the user API key when the user supplies the base URL', async () => { + const params = createParams({ + apiKey: 'sk-system-key', + baseURL: AuthType.USER_PROVIDED, + userApiKey: 'sk-user-owned-key', + userBaseURL: 'https://user-controlled.example.com/v1', + }); + + await initializeCustom(params); + + expect(mockGetOpenAIConfig).toHaveBeenCalledWith( + 'sk-user-owned-key', + expect.any(Object), + 'test-custom', + ); + expect(mockGetOpenAIConfig).not.toHaveBeenCalledWith( + 'sk-system-key', + expect.any(Object), + 'test-custom', + ); + }); + + it('throws NO_USER_KEY when the user supplies the base URL without an API key', async () => { + const params = createParams({ + apiKey: 'sk-system-key', + baseURL: AuthType.USER_PROVIDED, + userApiKey: '', + userBaseURL: 'https://user-controlled.example.com/v1', + }); + + await expect(initializeCustom(params)).rejects.toThrow(ErrorTypes.NO_USER_KEY); + expect(mockGetOpenAIConfig).not.toHaveBeenCalled(); + }); + + it.each(userControlledHeaderCases)( + 'withholds configured $headerType headers when the user supplies the base URL', + async ({ headers }) => { + const params = createParams({ + apiKey: 'sk-system-key', + baseURL: AuthType.USER_PROVIDED, + userBaseURL: 'https://user-controlled.example.com/v1', + headers, + }); + + await initializeCustom(params); + + const clientOptions = mockGetOpenAIConfig.mock.calls[0][1] as { + headers?: Record; + }; + expect(clientOptions.headers).toBeUndefined(); + }, + ); +}); + describe('initializeCustom – SSRF guard wiring', () => { beforeEach(() => { jest.clearAllMocks(); @@ -294,6 +398,7 @@ describe('initializeCustom – token-config fetch header forwarding', () => { expect(fetchModels).toHaveBeenCalledWith( expect.objectContaining({ name: 'openrouter', + apiKey: 'sk-user-key', headers: undefined, userObject: params.req.user, }), @@ -530,6 +635,7 @@ describe('initializeCustom – native Anthropic provider', () => { 'anthropicApiUrl', 'https://user-controlled.example.com', ); + expect(options.llmConfig).toHaveProperty('apiKey', 'sk-user-key'); const defaultHeaders = ( options.llmConfig as { clientOptions?: { defaultHeaders?: Record } } ).clientOptions?.defaultHeaders; diff --git a/packages/api/src/endpoints/custom/initialize.ts b/packages/api/src/endpoints/custom/initialize.ts index 80dfc4e8c0..b91abc76cd 100644 --- a/packages/api/src/endpoints/custom/initialize.ts +++ b/packages/api/src/endpoints/custom/initialize.ts @@ -94,9 +94,9 @@ function buildCustomOptions( endpointConfig: Partial, appConfig?: AppConfig, endpointTokenConfig?: Record, + forwardHeaders = true, ) { const customOptions: Record = { - headers: endpointConfig.headers, addParams: endpointConfig.addParams, dropParams: endpointConfig.dropParams, customParams: endpointConfig.customParams, @@ -110,6 +110,10 @@ function buildCustomOptions( endpointTokenConfig, }; + if (forwardHeaders) { + customOptions.headers = endpointConfig.headers; + } + const allConfig = appConfig?.endpoints?.all; if (allConfig) { customOptions.streamRate = allConfig.streamRate; @@ -209,10 +213,10 @@ export async function initializeCustom({ userValues = await db.getUserKeyValues({ userId: req.user?.id ?? '', name: endpoint }); } - const apiKey = userProvidesKey ? userValues?.apiKey : CUSTOM_API_KEY; + const apiKey = userProvidesKey || userProvidesURL ? userValues?.apiKey : CUSTOM_API_KEY; const baseURL = userProvidesURL ? userValues?.baseURL : CUSTOM_BASE_URL; - if (userProvidesKey && !apiKey) { + if ((userProvidesKey || userProvidesURL) && !apiKey) { throw new Error( JSON.stringify({ type: ErrorTypes.NO_USER_KEY, @@ -291,7 +295,12 @@ export async function initializeCustom({ endpointTokenConfig = (await cache.get(tokenKey)) as EndpointTokenConfig | undefined; } - const customOptions = buildCustomOptions(endpointConfig, appConfig, endpointTokenConfig); + const customOptions = buildCustomOptions( + endpointConfig, + appConfig, + endpointTokenConfig, + !userProvidesURL, + ); const clientOptions: Record = { reverseProxyUrl: baseURL ?? null,