🫷 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.
This commit is contained in:
Danny Avila 2026-03-15 18:41:59 -04:00 committed by GitHub
parent f9927f0168
commit f7ab5e645a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 446 additions and 1 deletions

View file

@ -0,0 +1,119 @@
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();
});
});

View file

@ -9,9 +9,10 @@ import type { TEndpoint } from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
import type { BaseInitializeParams, InitializeResultBase, EndpointTokenConfig } from '~/types';
import { getOpenAIConfig } from '~/endpoints/openai/config';
import { isUserProvided, checkUserKeyExpiry } from '~/utils';
import { getCustomEndpointConfig } from '~/app/config';
import { fetchModels } from '~/endpoints/models';
import { isUserProvided, checkUserKeyExpiry } from '~/utils';
import { validateEndpointURL } from '~/auth';
import { standardCache } from '~/cache';
const { PROXY } = process.env;
@ -123,6 +124,10 @@ export async function initializeCustom({
throw new Error(`${endpoint} Base URL not provided.`);
}
if (userProvidesURL) {
await validateEndpointURL(baseURL, endpoint);
}
let endpointTokenConfig: EndpointTokenConfig | undefined;
const userId = req.user?.id ?? '';

View file

@ -0,0 +1,135 @@
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();
});
});

View file

@ -6,6 +6,7 @@ import type {
UserKeyValues,
} from '~/types';
import { getAzureCredentials, resolveHeaders, isUserProvided, checkUserKeyExpiry } from '~/utils';
import { validateEndpointURL } from '~/auth';
import { getOpenAIConfig } from './config';
/**
@ -55,6 +56,10 @@ export async function initializeOpenAI({
? userValues?.baseURL
: baseURLOptions[endpoint as keyof typeof baseURLOptions];
if (userProvidesURL && baseURL) {
await validateEndpointURL(baseURL, endpoint);
}
const clientOptions: OpenAIConfigOptions = {
proxy: PROXY ?? undefined,
reverseProxyUrl: baseURL || undefined,