🫷 fix: Validate User-Provided Base URL in Endpoint Init#12248
Merged
Conversation
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.
Covers unparseable URLs, localhost, private IPs, link-local/metadata, internal Docker/K8s hostnames, DNS resolution to private IPs, and legitimate public URLs.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds SSRF-focused validation for user-supplied base URLs used by OpenAI/Azure and custom endpoints to prevent requests to private/internal targets.
Changes:
- Call a new
validateEndpointURL()helper when a baseURL is user-provided (OpenAI/Azure + custom endpoints). - Implement
validateEndpointURL()inauth/domain.tsusing existing SSRF hostname and DNS-to-private-IP checks. - Add Jest coverage for the new validation helper.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| packages/api/src/endpoints/openai/initialize.ts | Validate user-provided baseURL before using it as reverseProxyUrl. |
| packages/api/src/endpoints/custom/initialize.ts | Validate user-provided baseURL before initializing custom endpoint options. |
| packages/api/src/auth/domain.ts | Introduce validateEndpointURL() for SSRF-oriented baseURL validation. |
| packages/api/src/auth/domain.spec.ts | Add unit tests covering validateEndpointURL() behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
Comment on lines
+59
to
+61
| if (userProvidesURL && baseURL) { | ||
| await validateEndpointURL(baseURL, endpoint); | ||
| } |
Comment on lines
+127
to
+129
| if (userProvidesURL) { | ||
| await validateEndpointURL(baseURL, endpoint); | ||
| } |
Comment on lines
+505
to
+512
| * Throws if the URL is unparseable, targets a known SSRF hostname, or DNS-resolves to a private IP. | ||
| */ | ||
| export async function validateEndpointURL(url: string, endpoint: string): Promise<void> { | ||
| let hostname: string; | ||
| try { | ||
| const parsed = new URL(url); | ||
| hostname = parsed.hostname; | ||
| } catch { |
Comment on lines
+1219
to
+1283
| 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(); | ||
| }); |
- 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
…on 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
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.
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
Replace bare string literal 'invalid_base_url' with computed property [ErrorTypes.INVALID_BASE_URL] to match every other entry in the errorMessages map.
jcbartle
pushed a commit
to jcbartle/LibreChat
that referenced
this pull request
May 11, 2026
…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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixed an SSRF vulnerability where user-supplied
baseURLvalues (configured withuser_provided) were passed to the OpenAI SDK without validation in both the OpenAI/Azure and custom endpoint initialization paths, enabling arbitrary server-side requests to internal or metadata addresses.validateEndpointURLtopackages/api/src/auth/domain.ts, which enforces HTTP/HTTPS-only schemes, runsisSSRFTargetagainst the parsed hostname, and DNS-resolves viaresolveHostnameSSRFto block private IP targets; documents the DNS rebinding limitation and fail-open semantics in JSDoc.throwInvalidBaseURLhelper emittingJSON.stringify({ type: 'invalid_base_url', message })so all rejection paths produce structured errors consistent with the existingErrorTypespattern.initializeOpenAIbehind auserProvidesURL && baseURLcondition and intoinitializeCustombehinduserProvidesURL, both after their respective non-null guarantees.INVALID_BASE_URL = 'invalid_base_url'to theErrorTypesenum inpackages/data-provider/src/config.ts.[ErrorTypes.INVALID_BASE_URL]to'com_error_invalid_base_url'in theerrorMessagesmap inclient/src/components/Messages/Content/Error.tsxfor localized client-side rendering.com_error_invalid_base_urltoclient/src/locales/en/translation.json.packages/api/src/auth/domain.spec.tscovering unparseable URLs, localhost, private IPv4 ranges, link-local/metadata IPs, loopback, internal Docker/Kubernetes hostnames, DNS resolution to private IPs, non-HTTP/HTTPS schemes, IPv6 loopback/link-local/unique-local,.localand.internalTLDs, DNS fail-open, and structured JSON error format.packages/api/src/endpoints/custom/initialize.spec.tswith 3 integration tests asserting the guard fires whenuserProvidesURL, is skipped when system-defined, and propagates SSRF rejection beforegetOpenAIConfigis reached.packages/api/src/endpoints/openai/initialize.spec.tswith 4 integration tests covering the same guard wiring plus theuserProvidesURL && !baseURLskip path.Change Type
Testing
All new logic is exercised by the test suites added in this PR. To run them:
To manually verify the guard fires at runtime, configure a custom endpoint with
baseURL: user_providedinlibrechat.yaml, set a user key with a private-IP base URL (e.g.http://192.168.1.1/v1), and send a chat request — the error component should display the localizedcom_error_invalid_base_urlmessage rather than a raw 500.Test Configuration:
jest.mock('node:dns/promises')Checklist