mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-16 20:56:35 +01:00
* 🛡️ 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.
135 lines
3.8 KiB
TypeScript
135 lines
3.8 KiB
TypeScript
import { AuthType, EModelEndpoint } 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: 'gpt-4' },
|
||
configOptions: {},
|
||
});
|
||
jest.mock('./config', () => ({
|
||
getOpenAIConfig: (...args: unknown[]) => mockGetOpenAIConfig(...args),
|
||
}));
|
||
|
||
jest.mock('~/utils', () => ({
|
||
getAzureCredentials: jest.fn(),
|
||
resolveHeaders: jest.fn(() => ({})),
|
||
isUserProvided: (val: string) => val === 'user_provided',
|
||
checkUserKeyExpiry: jest.fn(),
|
||
}));
|
||
|
||
import { initializeOpenAI } from './initialize';
|
||
|
||
function createParams(env: Record<string, string | undefined>): BaseInitializeParams {
|
||
const savedEnv: Record<string, string | undefined> = {};
|
||
for (const key of Object.keys(env)) {
|
||
savedEnv[key] = process.env[key];
|
||
}
|
||
Object.assign(process.env, env);
|
||
|
||
const db = {
|
||
getUserKeyValues: jest.fn().mockResolvedValue({
|
||
apiKey: 'sk-user-key',
|
||
baseURL: 'https://user-proxy.example.com/v1',
|
||
}),
|
||
} as unknown as BaseInitializeParams['db'];
|
||
|
||
const params: BaseInitializeParams = {
|
||
req: {
|
||
user: { id: 'user-1' },
|
||
body: { key: '2099-01-01' },
|
||
config: { endpoints: {} },
|
||
} as unknown as BaseInitializeParams['req'],
|
||
endpoint: EModelEndpoint.openAI,
|
||
model_parameters: { model: 'gpt-4' },
|
||
db,
|
||
};
|
||
|
||
const restore = () => {
|
||
for (const key of Object.keys(env)) {
|
||
if (savedEnv[key] === undefined) {
|
||
delete process.env[key];
|
||
} else {
|
||
process.env[key] = savedEnv[key];
|
||
}
|
||
}
|
||
};
|
||
|
||
return Object.assign(params, { _restore: restore });
|
||
}
|
||
|
||
describe('initializeOpenAI – SSRF guard wiring', () => {
|
||
afterEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
it('should call validateEndpointURL when OPENAI_REVERSE_PROXY is user_provided', async () => {
|
||
const params = createParams({
|
||
OPENAI_API_KEY: 'sk-test',
|
||
OPENAI_REVERSE_PROXY: AuthType.USER_PROVIDED,
|
||
});
|
||
|
||
try {
|
||
await initializeOpenAI(params);
|
||
} finally {
|
||
(params as unknown as { _restore: () => void })._restore();
|
||
}
|
||
|
||
expect(mockValidateEndpointURL).toHaveBeenCalledTimes(1);
|
||
expect(mockValidateEndpointURL).toHaveBeenCalledWith(
|
||
'https://user-proxy.example.com/v1',
|
||
EModelEndpoint.openAI,
|
||
);
|
||
});
|
||
|
||
it('should NOT call validateEndpointURL when OPENAI_REVERSE_PROXY is a system URL', async () => {
|
||
const params = createParams({
|
||
OPENAI_API_KEY: 'sk-test',
|
||
OPENAI_REVERSE_PROXY: 'https://api.openai.com/v1',
|
||
});
|
||
|
||
try {
|
||
await initializeOpenAI(params);
|
||
} finally {
|
||
(params as unknown as { _restore: () => void })._restore();
|
||
}
|
||
|
||
expect(mockValidateEndpointURL).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should NOT call validateEndpointURL when baseURL is falsy', async () => {
|
||
const params = createParams({
|
||
OPENAI_API_KEY: 'sk-test',
|
||
});
|
||
|
||
try {
|
||
await initializeOpenAI(params);
|
||
} finally {
|
||
(params as unknown as { _restore: () => void })._restore();
|
||
}
|
||
|
||
expect(mockValidateEndpointURL).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('should propagate SSRF rejection from validateEndpointURL', async () => {
|
||
mockValidateEndpointURL.mockRejectedValueOnce(
|
||
new Error('Base URL for openAI targets a restricted address.'),
|
||
);
|
||
|
||
const params = createParams({
|
||
OPENAI_API_KEY: 'sk-test',
|
||
OPENAI_REVERSE_PROXY: AuthType.USER_PROVIDED,
|
||
});
|
||
|
||
try {
|
||
await expect(initializeOpenAI(params)).rejects.toThrow('targets a restricted address');
|
||
} finally {
|
||
(params as unknown as { _restore: () => void })._restore();
|
||
}
|
||
|
||
expect(mockGetOpenAIConfig).not.toHaveBeenCalled();
|
||
});
|
||
});
|