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 */