From f7ab5e645ad25ff36463a820d791d277afec985a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Mar 2026 18:41:59 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AB=B7=20fix:=20Validate=20User-Provided?= =?UTF-8?q?=20Base=20URL=20in=20Endpoint=20Init=20(#12248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿ›ก๏ธ 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. --- .../src/components/Messages/Content/Error.tsx | 1 + client/src/locales/en/translation.json | 1 + packages/api/src/auth/domain.spec.ts | 133 +++++++++++++++++ packages/api/src/auth/domain.ts | 42 ++++++ .../src/endpoints/custom/initialize.spec.ts | 119 +++++++++++++++ .../api/src/endpoints/custom/initialize.ts | 7 +- .../src/endpoints/openai/initialize.spec.ts | 135 ++++++++++++++++++ .../api/src/endpoints/openai/initialize.ts | 5 + packages/data-provider/src/config.ts | 4 + 9 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/endpoints/custom/initialize.spec.ts create mode 100644 packages/api/src/endpoints/openai/initialize.spec.ts diff --git a/client/src/components/Messages/Content/Error.tsx b/client/src/components/Messages/Content/Error.tsx index 469e29fe32..ff2f2d7e90 100644 --- a/client/src/components/Messages/Content/Error.tsx +++ b/client/src/components/Messages/Content/Error.tsx @@ -41,6 +41,7 @@ const errorMessages = { [ErrorTypes.NO_USER_KEY]: 'com_error_no_user_key', [ErrorTypes.INVALID_USER_KEY]: 'com_error_invalid_user_key', [ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url', + [ErrorTypes.INVALID_BASE_URL]: 'com_error_invalid_base_url', [ErrorTypes.INVALID_ACTION]: `com_error_${ErrorTypes.INVALID_ACTION}`, [ErrorTypes.INVALID_REQUEST]: `com_error_${ErrorTypes.INVALID_REQUEST}`, [ErrorTypes.REFUSAL]: 'com_error_refusal', diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index f45cdd5f8c..36d882c6a2 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -372,6 +372,7 @@ "com_error_missing_model": "No model selected for {{0}}. Please select a model and try again.", "com_error_models_not_loaded": "Models configuration could not be loaded. Please refresh the page and try again.", "com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.", + "com_error_invalid_base_url": "The base URL you provided targets a restricted address. Please use a valid external URL and try again.", "com_error_no_base_url": "No base URL found. Please provide one and try again.", "com_error_no_user_key": "No key found. Please provide a key and try again.", "com_error_refusal": "Response refused by safety filters. Rewrite your message and try again. If you encounter this frequently while using Claude Sonnet 4.5 or Opus 4.1, you can try Sonnet 4, which has different usage restrictions.", diff --git a/packages/api/src/auth/domain.spec.ts b/packages/api/src/auth/domain.spec.ts index 76f50213db..a7140528a9 100644 --- a/packages/api/src/auth/domain.spec.ts +++ b/packages/api/src/auth/domain.spec.ts @@ -12,6 +12,7 @@ import { isPrivateIP, isSSRFTarget, resolveHostnameSSRF, + validateEndpointURL, } from './domain'; const mockedLookup = lookup as jest.MockedFunction; @@ -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'); + }); +}); diff --git a/packages/api/src/auth/domain.ts b/packages/api/src/auth/domain.ts index 3babb09aa6..fabe2502ff 100644 --- a/packages/api/src/auth/domain.ts +++ b/packages/api/src/auth/domain.ts @@ -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 { + 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.`); + } +} diff --git a/packages/api/src/endpoints/custom/initialize.spec.ts b/packages/api/src/endpoints/custom/initialize.spec.ts new file mode 100644 index 0000000000..911e17c446 --- /dev/null +++ b/packages/api/src/endpoints/custom/initialize.spec.ts @@ -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(); + }); +}); diff --git a/packages/api/src/endpoints/custom/initialize.ts b/packages/api/src/endpoints/custom/initialize.ts index 7930b1c12f..15b6b873c7 100644 --- a/packages/api/src/endpoints/custom/initialize.ts +++ b/packages/api/src/endpoints/custom/initialize.ts @@ -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 ?? ''; diff --git a/packages/api/src/endpoints/openai/initialize.spec.ts b/packages/api/src/endpoints/openai/initialize.spec.ts new file mode 100644 index 0000000000..ae91571fb3 --- /dev/null +++ b/packages/api/src/endpoints/openai/initialize.spec.ts @@ -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): BaseInitializeParams { + const savedEnv: Record = {}; + 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(); + }); +}); diff --git a/packages/api/src/endpoints/openai/initialize.ts b/packages/api/src/endpoints/openai/initialize.ts index 33ce233d34..a6ad6df895 100644 --- a/packages/api/src/endpoints/openai/initialize.ts +++ b/packages/api/src/endpoints/openai/initialize.ts @@ -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, diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index e13521c019..bb0c180209 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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 */