🤐 fix: Withhold Custom Endpoint Headers for User URLs (#13917)

* 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
This commit is contained in:
Danny Avila 2026-06-23 15:49:31 -04:00 committed by GitHub
parent f14309e087
commit 606292c5c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 202 additions and 8 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -42,6 +42,7 @@ function createParams(overrides: {
userBaseURL?: string;
userApiKey?: string;
expiresAt?: string;
headers?: Record<string, string>;
}): 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<string, string>;
}> = [
{
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<string, string>;
};
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<string, string>;
};
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<string, string> } }
).clientOptions?.defaultHeaders;

View file

@ -94,9 +94,9 @@ function buildCustomOptions(
endpointConfig: Partial<TEndpoint>,
appConfig?: AppConfig,
endpointTokenConfig?: Record<string, unknown>,
forwardHeaders = true,
) {
const customOptions: Record<string, unknown> = {
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<string, unknown> = {
reverseProxyUrl: baseURL ?? null,