Skip to content

🛡️ fix: SSRF-Guard Speech (STT/TTS) and OCR Outbound Requests at Connect Time - #14560

Open
dustinhealy wants to merge 3 commits into
fix/speech-ocr-allowedaddressesfrom
fix/speech-ocr-ssrf-guard
Open

🛡️ fix: SSRF-Guard Speech (STT/TTS) and OCR Outbound Requests at Connect Time#14560
dustinhealy wants to merge 3 commits into
fix/speech-ocr-allowedaddressesfrom
fix/speech-ocr-ssrf-guard

Conversation

@dustinhealy

Copy link
Copy Markdown
Collaborator

Summary

The speech (STT/TTS) and OCR integrations issue outbound HTTP to operator-provided target URLs (STTService.js, TTSService.js, packages/api/src/files/mistral/crud.ts) with only applyAxiosProxyConfig attached, so they have no SSRF protection: a target URL, or a hostname that DNS-rebinds after a write-time check, that resolves to private, loopback, link-local, or cloud-metadata address space (169.254.169.254, metadata.google.internal, RFC1918, CGNAT) is reached by the server. This is the same CWE-918 class already fixed for the custom /models fetch (#13919), Actions, avatar, the OpenAI and Anthropic endpoint clients, and MCP; speech and OCR are the remaining unguarded clients of this kind in this repo.

The fix reuses the established framework: it attaches createSSRFSafeAgents(allowedAddresses) (from #11722, extended by #12933) to the axios config for each of these calls, validating the resolved IP at TCP-connect time so it holds against DNS rebinding that a write-time URL check cannot catch. The speech.stt.allowedAddresses, speech.tts.allowedAddresses, and ocr.allowedAddresses fields (added in the stacked base PR) are threaded in as the per-address exemption, so operators pointing speech or OCR at localhost or a LAN host (LocalAI, a self-hosted Whisper server, a private Mistral-compatible OCR endpoint) can opt those exact host:port targets back in. Proxy precedence is preserved: if a proxy or agent is already set, the SSRF agents are not attached. Redirects are disabled on the guarded calls (maxRedirects: 0) so the connect-time check cannot be bypassed by a redirect to an internal host, and non-http(s) or unparseable target URLs are rejected before the request so file: or gopher: cannot reach the client.

Scope, stated plainly: this addresses private/internal SSRF only. It does not address forwarding an operator credential to a public host, since a private-IP guard never fires on a public address; restricting which public hosts are reachable is a separate control (a positive-host egress allowlist) and is not part of this PR.

Accepted tradeoff: with no allowedAddresses configured, requests to private or link-local targets now fail closed, matching how endpoints, actions, and MCP already behave. Self-hosters using a localhost speech or OCR endpoint add a one-line exemption, documented in librechat.example.yaml. allowedAddresses entries are trusted before the private-IP check, so operators must list only hosts they fully control and that cannot be repointed or DNS-rebound by an attacker.

Change Type

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

Testing

Tests are behavioral rather than mock-only: the ESSRF cases exercise the real createSSRFSafeAgents lookup against a private target, since a fully-mocked axios would not catch a missing exemption, a missing maxRedirects: 0, or an agent that does not actually block a private connect.

  • packages/api/src/auth/agent.spec.ts: with an http(s) URL and no proxy/agent, both agents are attached and config.maxRedirects === 0; a target resolving to a private IP rejects with err.code === 'ESSRF' through the real lookup; a host:port in allowedAddresses succeeds; a non-http(s) URL (file:, gopher:) or malformed URL is rejected by the helper's scheme/parse guard; when a proxy or pre-set agent is present, the agents are not attached and maxRedirects is still 0.
  • packages/api/src/files/mistral/crud.spec.ts: the axios config now carries httpAgent/httpsAgent and maxRedirects: 0 (updated expectations); a private-IP baseURL rejects with ESSRF through the real lookup; an ocr.allowedAddresses host:port is exempted; a pre-set proxy short-circuits with maxRedirects still 0.
  • STT/TTS specs: a private target rejects with ESSRF, an allowedAddresses host:port succeeds, maxRedirects === 0 on the built config, and provider selection still resolves exactly one provider when allowedAddresses is set.

All added specs pass and tsc --noEmit is clean for packages/api.

Checklist

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

… time

Speech (STT/TTS) and OCR issued outbound HTTP to operator-provided target URLs
with only proxy config attached, so a target that resolves to a private, loopback,
link-local, or cloud-metadata address was reachable by the server. This is the same
class already guarded for the custom models fetch, Actions, avatar, MCP, and the
OpenAI/Anthropic endpoint clients.

Add one helper applySSRFSafeAgentIfDirect(config, url, allowedAddresses) that rejects
non-http(s) or unparseable target URLs, sets maxRedirects to 0 so a redirect cannot
bypass the connect-time check, and attaches createSSRFSafeAgents when no proxy or agent
is already set (proxy precedence preserved). Wire it into the six speech/OCR call sites
and thread each section's allowedAddresses exemption from pr-01.

Scope is private/internal SSRF only. It does not restrict forwarding a credential to a
public host, which is a separate egress-allowlist concern. Absent an allowedAddresses
entry, private targets now fail closed, matching endpoints, actions, and MCP. Document
the new fields in librechat.example.yaml with the operator warning that allowedAddresses
hostnames are trusted before the private-IP check.
@dustinhealy

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 68a293bc71

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/api/src/auth/agent.ts
Comment thread api/server/services/Files/Audio/STTService.js
… generic uploads

applySSRFSafeAgentIfDirect only attached the DNS-lookup agents, but Node
skips the custom lookup for IP-literal hosts, so a literal private IP such
as http://127.0.0.1 connected unchecked. Reject literal private IPs
synchronously in the helper, reusing the same allowedAddresses exemption
logic as the lookup path.

The generic audio-upload path did not forward the section-level
allowedAddresses to sttRequest, so a private STT endpoint permitted via
speech.stt.allowedAddresses failed with ESSRF outside the speech route.
Thread the exemption through files/audio.ts and widen the STTService type.
Copilot AI review requested due to automatic review settings August 1, 2026 05:09
@dustinhealy

Copy link
Copy Markdown
Collaborator Author

@codex re-review

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

This PR extends LibreChat’s existing SSRF hardening to cover speech (STT/TTS) and Mistral OCR outbound HTTP requests by attaching SSRF-safe connect-time agents (DNS-rebinding resistant) and disabling redirects, while supporting operator opt-ins via allowedAddresses exemptions.

Changes:

  • Add applySSRFSafeAgentIfDirect() and apply it to OCR and speech outbound axios calls (plus maxRedirects: 0).
  • Thread section-level allowedAddresses through STT/TTS and OCR request construction to allow explicit private host:port exemptions.
  • Add/adjust Jest coverage for SSRF blocking, exemptions, and proxy precedence; document the new config in librechat.example.yaml.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/api/src/types/files.ts Extends STT service typing to thread allowedAddresses through schema selection and request execution.
packages/api/src/files/mistral/crud.ts Applies SSRF-safe axios agent attachment (and redirect disabling) to Mistral OCR upload/sign URL/OCR/delete calls; threads ocr.allowedAddresses.
packages/api/src/files/mistral/crud.spec.ts Updates existing assertions for new axios config fields and adds SSRF guard behavioral tests.
packages/api/src/files/audio.ts Threads section-level allowedAddresses from getProviderSchema() into sttRequest().
packages/api/src/files/audio.spec.ts Adds a unit test ensuring allowedAddresses is passed through to the STT request.
packages/api/src/auth/agent.ts Introduces applySSRFSafeAgentIfDirect() for axios SSRF guard wiring at connect time (+ redirect disabling).
packages/api/src/auth/agent.spec.ts Adds focused tests for applySSRFSafeAgentIfDirect() behavior, including scheme rejection, proxy/agent precedence, and ESSRF blocking.
librechat.example.yaml Documents SSRF-guard behavior for speech and OCR and the allowedAddresses exemption format.
api/server/services/Files/Audio/TTSService.js Applies SSRF-safe agent wiring to TTS axios calls and threads speech.tts.allowedAddresses.
api/server/services/Files/Audio/STTService.js Applies SSRF-safe agent wiring to STT axios calls and threads speech.stt.allowedAddresses.
api/server/services/Files/Audio/speechSSRF.spec.js Adds integration-style SSRF tests for STT/TTS using the “real” agent lookup path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/api/src/auth/agent.ts
Comment thread librechat.example.yaml Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 501bb03218

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/api/src/auth/agent.ts Outdated
Comment thread packages/api/src/files/mistral/crud.ts
…et before opening its stream

The literal-IP precheck normalized an empty URL.port to '', so
allowedAddresses exemptions on a default port (127.0.0.1:80, [::1]:443)
never matched. Derive 80/443 from the scheme when the port is omitted.

uploadDocumentToMistral opened the upload file stream before the SSRF
check, so a blocked or malformed target threw with the descriptor still
open. Run the proxy/SSRF setup before fs.createReadStream.

Reword the librechat.example.yaml allowedAddresses guidance to prefer a
private IP literal over a hostname, and reconcile the speech/OCR SSRF
specs to assert the synchronous literal-IP block instead of driving the
connect-time lookup that Node skips for IP literals.
@dustinhealy

Copy link
Copy Markdown
Collaborator Author

@codex re-review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 7ca4e18312

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@dustinhealy
dustinhealy marked this pull request as ready for review August 1, 2026 05:48

@danny-avila danny-avila left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Additional review findings.

}

config.maxRedirects = 0;
if (config.httpsAgent || config.httpAgent || config.proxy) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[P1] The proxy or agent early return precedes the literal private-IP check. Because each call site invokes applyAxiosProxyConfig first, applySSRFSafeAgentIfDirect({ proxy: ... }, 'http://169.254.169.254') returns without ESSRF; Axios asks the proxy to fetch the metadata target. Move literal validation above this return. For hostnames, require an SSRF-enforcing trusted proxy or add a preflight guard.

applyAxiosProxyConfig(config, `${baseURL}/files`);
applySSRFSafeAgentIfDirect(config, `${baseURL}/files`, allowedAddresses);

const fileStream = fs.createReadStream(filePath);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[P2] Moving validation before createReadStream fixes only synchronous URL and literal-IP failures. A hostname that resolves privately is rejected later by the agent during axios.post; Axios destroys the request but not this FormData source, leaving the paused ReadStream and fd open. Destroy fileStream in finally and add an async DNS ESSRF cleanup test.

if (isIP(literalHost)) {
const exemptSet = normalizeAllowedAddressesSet(allowedAddresses);
const normalizedPort = normalizePort(port || (protocol === 'https:' ? '443' : '80'));
const hostnameAllowed = isAddressInAllowedSet(literalHost, exemptSet, normalizedPort);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[P2] IPv6 exemptions are compared textually after URL canonicalization. The schema accepts [::ffff:127.0.0.1]:8080, but new URL(...).hostname yields [::ffff:7f00:1]; expanded ULA forms compress similarly, so equivalent exemptions throw ESSRF. Canonicalize IP literals in both allowlist normalization and target comparison, then test mapped and expanded IPv6 forms.

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.

3 participants