LibreChat/packages/api/src/endpoints/custom/initialize.spec.ts
Danny Avila f7ab5e645a
🫷 fix: Validate User-Provided Base URL in Endpoint Init (#12248)
* 🛡️ fix: Block SSRF via user-provided baseURL in endpoint initialization

User-provided baseURL values (when endpoint is configured with
`user_provided`) were passed through to the OpenAI SDK without
validation. Combined with `directEndpoint`, this allowed arbitrary
server-side requests to internal/metadata URLs.

Adds `validateEndpointURL` that checks against known SSRF targets
and DNS-resolves hostnames to block private IPs. Applied in both
custom and OpenAI endpoint initialization paths.

* 🧪 test: Add validateEndpointURL SSRF tests

Covers unparseable URLs, localhost, private IPs, link-local/metadata,
internal Docker/K8s hostnames, DNS resolution to private IPs, and
legitimate public URLs.

* 🛡️ fix: Add protocol enforcement and import order fix

- Reject non-HTTP/HTTPS schemes (ftp://, file://, data:, etc.) in
  validateEndpointURL before SSRF hostname checks
- Document DNS rebinding limitation and fail-open semantics in JSDoc
- Fix import order in custom/initialize.ts per project conventions

* 🧪 test: Expand SSRF validation coverage and add initializer integration tests

Unit tests for validateEndpointURL:
- Non-HTTP/HTTPS schemes (ftp, file, data)
- IPv6 loopback, link-local, and unique-local addresses
- .local and .internal TLD hostnames
- DNS fail-open path (lookup failure allows request)

Integration tests for initializeCustom and initializeOpenAI:
- Guard fires when userProvidesURL is true
- Guard skipped when URL is system-defined or falsy
- SSRF rejection propagates and prevents getOpenAIConfig call

* 🐛 fix: Correct broken env restore in OpenAI initialize spec

process.env was captured by reference, not by value, making the
restore closure a no-op. Snapshot individual env keys before mutation
so they can be properly restored after each test.

* 🛡️ fix: Throw structured ErrorTypes for SSRF base URL validation

Replace plain-string Error throws in validateEndpointURL with
JSON-structured errors using type 'invalid_base_url' (matching new
ErrorTypes.INVALID_BASE_URL enum value). This ensures the client-side
Error component can look up a localized message instead of falling
through to the raw-text default.

Changes across workspaces:
- data-provider: add INVALID_BASE_URL to ErrorTypes enum
- packages/api: throwInvalidBaseURL helper emits structured JSON
- client: add errorMessages entry and localization key
- tests: add structured JSON format assertion

* 🧹 refactor: Use ErrorTypes enum key in Error.tsx for consistency

Replace bare string literal 'invalid_base_url' with computed property
[ErrorTypes.INVALID_BASE_URL] to match every other entry in the
errorMessages map.
2026-03-15 18:41:59 -04:00

119 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { AuthType } from 'librechat-data-provider';
import type { BaseInitializeParams } from '~/types';
const mockValidateEndpointURL = jest.fn();
jest.mock('~/auth', () => ({
validateEndpointURL: (...args: unknown[]) => mockValidateEndpointURL(...args),
}));
const mockGetOpenAIConfig = jest.fn().mockReturnValue({
llmConfig: { model: 'test-model' },
configOptions: {},
});
jest.mock('~/endpoints/openai/config', () => ({
getOpenAIConfig: (...args: unknown[]) => mockGetOpenAIConfig(...args),
}));
jest.mock('~/endpoints/models', () => ({
fetchModels: jest.fn(),
}));
jest.mock('~/cache', () => ({
standardCache: jest.fn(() => ({ get: jest.fn().mockResolvedValue(null) })),
}));
jest.mock('~/utils', () => ({
isUserProvided: (val: string) => val === 'user_provided',
checkUserKeyExpiry: jest.fn(),
}));
const mockGetCustomEndpointConfig = jest.fn();
jest.mock('~/app/config', () => ({
getCustomEndpointConfig: (...args: unknown[]) => mockGetCustomEndpointConfig(...args),
}));
import { initializeCustom } from './initialize';
function createParams(overrides: {
apiKey?: string;
baseURL?: string;
userBaseURL?: string;
userApiKey?: string;
expiresAt?: string;
}): BaseInitializeParams {
const { apiKey = 'sk-test-key', baseURL = 'https://api.example.com/v1' } = overrides;
mockGetCustomEndpointConfig.mockReturnValue({
apiKey,
baseURL,
models: {},
});
const db = {
getUserKeyValues: jest.fn().mockResolvedValue({
apiKey: overrides.userApiKey ?? 'sk-user-key',
baseURL: overrides.userBaseURL ?? 'https://user-api.example.com/v1',
}),
} as unknown as BaseInitializeParams['db'];
return {
req: {
user: { id: 'user-1' },
body: { key: overrides.expiresAt ?? '2099-01-01' },
config: {},
} as unknown as BaseInitializeParams['req'],
endpoint: 'test-custom',
model_parameters: { model: 'gpt-4' },
db,
};
}
describe('initializeCustom SSRF guard wiring', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call validateEndpointURL when baseURL is user_provided', async () => {
const params = createParams({
apiKey: 'sk-test-key',
baseURL: AuthType.USER_PROVIDED,
userBaseURL: 'https://user-api.example.com/v1',
expiresAt: '2099-01-01',
});
await initializeCustom(params);
expect(mockValidateEndpointURL).toHaveBeenCalledTimes(1);
expect(mockValidateEndpointURL).toHaveBeenCalledWith(
'https://user-api.example.com/v1',
'test-custom',
);
});
it('should NOT call validateEndpointURL when baseURL is system-defined', async () => {
const params = createParams({
apiKey: 'sk-test-key',
baseURL: 'https://api.provider.com/v1',
});
await initializeCustom(params);
expect(mockValidateEndpointURL).not.toHaveBeenCalled();
});
it('should propagate SSRF rejection from validateEndpointURL', async () => {
mockValidateEndpointURL.mockRejectedValueOnce(
new Error('Base URL for test-custom targets a restricted address.'),
);
const params = createParams({
apiKey: 'sk-test-key',
baseURL: AuthType.USER_PROVIDED,
userBaseURL: 'http://169.254.169.254/latest/meta-data/',
expiresAt: '2099-01-01',
});
await expect(initializeCustom(params)).rejects.toThrow('targets a restricted address');
expect(mockGetOpenAIConfig).not.toHaveBeenCalled();
});
});