🫷 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

@ -12,6 +12,7 @@ import {
isPrivateIP,
isSSRFTarget,
resolveHostnameSSRF,
validateEndpointURL,
} from './domain';
const mockedLookup = lookup as jest.MockedFunction<typeof lookup>;
@ -1209,3 +1210,135 @@ describe('isMCPDomainAllowed', () => {
});
});
});
describe('validateEndpointURL', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should throw for unparseable URLs', async () => {
await expect(validateEndpointURL('not-a-url', 'test-ep')).rejects.toThrow(
'Invalid base URL for test-ep',
);
});
it('should throw for localhost URLs', async () => {
await expect(validateEndpointURL('http://localhost:8080/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for private IP URLs', async () => {
await expect(validateEndpointURL('http://192.168.1.1/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
await expect(validateEndpointURL('http://10.0.0.1/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
await expect(validateEndpointURL('http://172.16.0.1/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for link-local / metadata IP', async () => {
await expect(
validateEndpointURL('http://169.254.169.254/latest/meta-data/', 'test-ep'),
).rejects.toThrow('targets a restricted address');
});
it('should throw for loopback IP', async () => {
await expect(validateEndpointURL('http://127.0.0.1:11434/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for internal Docker/Kubernetes hostnames', async () => {
await expect(validateEndpointURL('http://redis:6379/', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
await expect(validateEndpointURL('http://mongodb:27017/', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw when hostname DNS-resolves to a private IP', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
await expect(validateEndpointURL('https://evil.example.com/v1', 'test-ep')).rejects.toThrow(
'resolves to a restricted address',
);
});
it('should allow public URLs', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '104.18.7.192', family: 4 }] as never);
await expect(
validateEndpointURL('https://api.openai.com/v1', 'test-ep'),
).resolves.toBeUndefined();
});
it('should allow public URLs that resolve to public IPs', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }] as never);
await expect(
validateEndpointURL('https://api.example.com/v1/chat', 'test-ep'),
).resolves.toBeUndefined();
});
it('should throw for non-HTTP/HTTPS schemes', async () => {
await expect(validateEndpointURL('ftp://example.com/v1', 'test-ep')).rejects.toThrow(
'only HTTP and HTTPS are permitted',
);
await expect(validateEndpointURL('file:///etc/passwd', 'test-ep')).rejects.toThrow(
'only HTTP and HTTPS are permitted',
);
await expect(validateEndpointURL('data:text/plain,hello', 'test-ep')).rejects.toThrow(
'only HTTP and HTTPS are permitted',
);
});
it('should throw for IPv6 loopback URL', async () => {
await expect(validateEndpointURL('http://[::1]:8080/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for IPv6 link-local URL', async () => {
await expect(validateEndpointURL('http://[fe80::1]/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for IPv6 unique-local URL', async () => {
await expect(validateEndpointURL('http://[fc00::1]/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for .local TLD hostname', async () => {
await expect(validateEndpointURL('http://myservice.local/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should throw for .internal TLD hostname', async () => {
await expect(validateEndpointURL('http://api.internal/v1', 'test-ep')).rejects.toThrow(
'targets a restricted address',
);
});
it('should pass when DNS lookup fails (fail-open)', async () => {
mockedLookup.mockRejectedValueOnce(new Error('ENOTFOUND'));
await expect(
validateEndpointURL('https://nonexistent.example.com/v1', 'test-ep'),
).resolves.toBeUndefined();
});
it('should throw structured JSON with type invalid_base_url', async () => {
const error = await validateEndpointURL('http://169.254.169.254/latest/', 'my-ep').catch(
(err: Error) => err,
);
expect(error).toBeInstanceOf(Error);
const parsed = JSON.parse((error as Error).message);
expect(parsed.type).toBe('invalid_base_url');
expect(parsed.message).toContain('my-ep');
expect(parsed.message).toContain('targets a restricted address');
});
});

View file

@ -499,3 +499,45 @@ export async function isMCPDomainAllowed(
// Use MCP_PROTOCOLS (HTTP/HTTPS/WS/WSS) for MCP server validation
return isDomainAllowedCore(domain, allowedDomains, MCP_PROTOCOLS);
}
/** Matches ErrorTypes.INVALID_BASE_URL — string literal avoids build-time dependency on data-provider */
const INVALID_BASE_URL_TYPE = 'invalid_base_url';
function throwInvalidBaseURL(message: string): never {
throw new Error(JSON.stringify({ type: INVALID_BASE_URL_TYPE, message }));
}
/**
* Validates that a user-provided endpoint URL does not target private/internal addresses.
* Throws if the URL is unparseable, uses a non-HTTP(S) scheme, targets a known SSRF hostname,
* or DNS-resolves to a private IP.
*
* @note DNS rebinding: validation performs a single DNS lookup. An adversary controlling
* DNS with TTL=0 could respond with a public IP at validation time and a private IP
* at request time. This is an accepted limitation of point-in-time DNS checks.
* @note Fail-open on DNS errors: a resolution failure here implies a failure at request
* time as well, matching {@link resolveHostnameSSRF} semantics.
*/
export async function validateEndpointURL(url: string, endpoint: string): Promise<void> {
let hostname: string;
let protocol: string;
try {
const parsed = new URL(url);
hostname = parsed.hostname;
protocol = parsed.protocol;
} catch {
throwInvalidBaseURL(`Invalid base URL for ${endpoint}: unable to parse URL.`);
}
if (protocol !== 'http:' && protocol !== 'https:') {
throwInvalidBaseURL(`Invalid base URL for ${endpoint}: only HTTP and HTTPS are permitted.`);
}
if (isSSRFTarget(hostname)) {
throwInvalidBaseURL(`Base URL for ${endpoint} targets a restricted address.`);
}
if (await resolveHostnameSSRF(hostname)) {
throwInvalidBaseURL(`Base URL for ${endpoint} resolves to a restricted address.`);
}
}

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,

View file

@ -1560,6 +1560,10 @@ export enum ErrorTypes {
* No Base URL Provided.
*/
NO_BASE_URL = 'no_base_url',
/**
* Base URL targets a restricted or invalid address (SSRF protection).
*/
INVALID_BASE_URL = 'invalid_base_url',
/**
* Moderation error
*/