Skip to content

🫷 fix: Validate User-Provided Base URL in Endpoint Init#12248

Merged
danny-avila merged 7 commits into
devfrom
fix/ssrf-user-provided-baseurl
Mar 15, 2026
Merged

🫷 fix: Validate User-Provided Base URL in Endpoint Init#12248
danny-avila merged 7 commits into
devfrom
fix/ssrf-user-provided-baseurl

Conversation

@danny-avila

@danny-avila danny-avila commented Mar 15, 2026

Copy link
Copy Markdown
Owner

Summary

Fixed an SSRF vulnerability where user-supplied baseURL values (configured with user_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.

  • Added validateEndpointURL to packages/api/src/auth/domain.ts, which enforces HTTP/HTTPS-only schemes, runs isSSRFTarget against the parsed hostname, and DNS-resolves via resolveHostnameSSRF to block private IP targets; documents the DNS rebinding limitation and fail-open semantics in JSDoc.
  • Added throwInvalidBaseURL helper emitting JSON.stringify({ type: 'invalid_base_url', message }) so all rejection paths produce structured errors consistent with the existing ErrorTypes pattern.
  • Wired the guard into initializeOpenAI behind a userProvidesURL && baseURL condition and into initializeCustom behind userProvidesURL, both after their respective non-null guarantees.
  • Added INVALID_BASE_URL = 'invalid_base_url' to the ErrorTypes enum in packages/data-provider/src/config.ts.
  • Mapped [ErrorTypes.INVALID_BASE_URL] to 'com_error_invalid_base_url' in the errorMessages map in client/src/components/Messages/Content/Error.tsx for localized client-side rendering.
  • Added com_error_invalid_base_url to client/src/locales/en/translation.json.
  • Added 16 unit tests to packages/api/src/auth/domain.spec.ts covering 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, .local and .internal TLDs, DNS fail-open, and structured JSON error format.
  • Added packages/api/src/endpoints/custom/initialize.spec.ts with 3 integration tests asserting the guard fires when userProvidesURL, is skipped when system-defined, and propagates SSRF rejection before getOpenAIConfig is reached.
  • Added packages/api/src/endpoints/openai/initialize.spec.ts with 4 integration tests covering the same guard wiring plus the userProvidesURL && !baseURL skip path.

Change Type

  • Bug fix (non-breaking change which fixes an issue)

Testing

All new logic is exercised by the test suites added in this PR. To run them:

cd packages/api && npx jest auth/domain && npx jest endpoints/custom/initialize && npx jest endpoints/openai/initialize

To manually verify the guard fires at runtime, configure a custom endpoint with baseURL: user_provided in librechat.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 localized com_error_invalid_base_url message rather than a raw 500.

Test Configuration:

  • Node.js v20+
  • No external services required; DNS lookups are mocked via jest.mock('node:dns/promises')
  • 204 tests passing across 3 suites

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes

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.
Copilot AI review requested due to automatic review settings March 15, 2026 21:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() in auth/domain.ts using 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 thread packages/api/src/auth/domain.ts Outdated
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.
@danny-avila danny-avila changed the title fix/validate user provided baseurl 🫷 fix: Validate User-Provided Base URL in Endpoint Init Mar 15, 2026
@danny-avila danny-avila merged commit f7ab5e6 into dev Mar 15, 2026
13 checks passed
@danny-avila danny-avila deleted the fix/ssrf-user-provided-baseurl branch March 15, 2026 22:42
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants