mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
🤐 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:
parent
f14309e087
commit
606292c5c5
6 changed files with 202 additions and 8 deletions
|
|
@ -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}}',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue