fix: use SSRF-guarded fetch for TTS provider requests#46333
Conversation
ElevenLabs and OpenAI TTS functions used raw fetch() with user-controlled baseUrl parameters. An attacker with operator.admin scope could redirect baseUrl to exfiltrate API keys (xi-api-key, Authorization Bearer) and enable SSRF against internal services. Replace raw fetch() with fetchWithSsrFGuard(withStrictGuardedFetchMode()) which enforces DNS pinning and private IP blocking.
Greptile SummaryThis PR closes an SSRF vulnerability in
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/tts/tts-core.ts
Line: 599-626
Comment:
**Consider passing `signal` at the top-level and adding `auditContext`**
`fetchWithSsrFGuard` has a dedicated top-level `signal` field on `GuardedFetchOptions` which is processed by `buildAbortSignal` (handling event-listener cleanup and chaining). Placing it inside `init` instead works today (the inner signal is preserved when `params.signal` and `params.timeoutMs` are both absent, since `buildAbortSignal` returns `undefined`), but it is fragile — if `timeoutMs` is ever added to these call sites at the `fetchWithSsrFGuard` level the inner `init.signal` would be silently overridden by the composed signal.
Additionally, passing `auditContext` provides far more useful context in security logs when a request is SSRF-blocked (currently logs will show the generic `"url-fetch"` string).
Suggested refactor for `elevenLabsTTS`:
```ts
const { response, release } = await fetchWithSsrFGuard(
withStrictGuardedFetchMode({
url: url.toString(),
signal: controller.signal, // ← top-level, not inside init
auditContext: "elevenlabs-tts", // ← named context in SSRF block logs
init: {
method: "POST",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
Accept: "audio/mpeg",
},
body: JSON.stringify({ ... }),
// signal removed from here
},
}),
);
```
And analogously for `openaiTTS` with `auditContext: "openai-tts"`.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/tts/tts-core.ts
Line: 668-688
Comment:
**Same `signal`-in-`init` / missing `auditContext` concern**
Same issue as in `elevenLabsTTS` above: `signal: controller.signal` is buried inside `init` rather than passed as the top-level `signal` field, and no `auditContext` is provided to identify this call site in SSRF audit logs. Consider moving `signal` to the top level and adding `auditContext: "openai-tts"` for consistency and safety.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 6fbc4c2 |
| const { response, release } = await fetchWithSsrFGuard( | ||
| withStrictGuardedFetchMode({ | ||
| url: url.toString(), | ||
| init: { | ||
| method: "POST", | ||
| headers: { | ||
| "xi-api-key": apiKey, | ||
| "Content-Type": "application/json", | ||
| Accept: "audio/mpeg", | ||
| }, | ||
| body: JSON.stringify({ | ||
| text, | ||
| model_id: modelId, | ||
| seed: normalizedSeed, | ||
| apply_text_normalization: normalizedNormalization, | ||
| language_code: normalizedLanguage, | ||
| voice_settings: { | ||
| stability: voiceSettings.stability, | ||
| similarity_boost: voiceSettings.similarityBoost, | ||
| style: voiceSettings.style, | ||
| use_speaker_boost: voiceSettings.useSpeakerBoost, | ||
| speed: voiceSettings.speed, | ||
| }, | ||
| }), | ||
| signal: controller.signal, | ||
| }, | ||
| }), | ||
| signal: controller.signal, | ||
| }); | ||
| ); |
There was a problem hiding this comment.
Consider passing signal at the top-level and adding auditContext
fetchWithSsrFGuard has a dedicated top-level signal field on GuardedFetchOptions which is processed by buildAbortSignal (handling event-listener cleanup and chaining). Placing it inside init instead works today (the inner signal is preserved when params.signal and params.timeoutMs are both absent, since buildAbortSignal returns undefined), but it is fragile — if timeoutMs is ever added to these call sites at the fetchWithSsrFGuard level the inner init.signal would be silently overridden by the composed signal.
Additionally, passing auditContext provides far more useful context in security logs when a request is SSRF-blocked (currently logs will show the generic "url-fetch" string).
Suggested refactor for elevenLabsTTS:
const { response, release } = await fetchWithSsrFGuard(
withStrictGuardedFetchMode({
url: url.toString(),
signal: controller.signal, // ← top-level, not inside init
auditContext: "elevenlabs-tts", // ← named context in SSRF block logs
init: {
method: "POST",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
Accept: "audio/mpeg",
},
body: JSON.stringify({ ... }),
// signal removed from here
},
}),
);And analogously for openaiTTS with auditContext: "openai-tts".
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tts/tts-core.ts
Line: 599-626
Comment:
**Consider passing `signal` at the top-level and adding `auditContext`**
`fetchWithSsrFGuard` has a dedicated top-level `signal` field on `GuardedFetchOptions` which is processed by `buildAbortSignal` (handling event-listener cleanup and chaining). Placing it inside `init` instead works today (the inner signal is preserved when `params.signal` and `params.timeoutMs` are both absent, since `buildAbortSignal` returns `undefined`), but it is fragile — if `timeoutMs` is ever added to these call sites at the `fetchWithSsrFGuard` level the inner `init.signal` would be silently overridden by the composed signal.
Additionally, passing `auditContext` provides far more useful context in security logs when a request is SSRF-blocked (currently logs will show the generic `"url-fetch"` string).
Suggested refactor for `elevenLabsTTS`:
```ts
const { response, release } = await fetchWithSsrFGuard(
withStrictGuardedFetchMode({
url: url.toString(),
signal: controller.signal, // ← top-level, not inside init
auditContext: "elevenlabs-tts", // ← named context in SSRF block logs
init: {
method: "POST",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
Accept: "audio/mpeg",
},
body: JSON.stringify({ ... }),
// signal removed from here
},
}),
);
```
And analogously for `openaiTTS` with `auditContext: "openai-tts"`.
How can I resolve this? If you propose a fix, please make it concise.| const { response, release } = await fetchWithSsrFGuard( | ||
| withStrictGuardedFetchMode({ | ||
| url: `${baseUrl}/audio/speech`, | ||
| init: { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${apiKey}`, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| model, | ||
| input: text, | ||
| voice, | ||
| response_format: responseFormat, | ||
| ...(speed != null && { speed }), | ||
| ...(effectiveInstructions != null && { instructions: effectiveInstructions }), | ||
| }), | ||
| signal: controller.signal, | ||
| }, | ||
| }), | ||
| signal: controller.signal, | ||
| }); | ||
| ); |
There was a problem hiding this comment.
Same signal-in-init / missing auditContext concern
Same issue as in elevenLabsTTS above: signal: controller.signal is buried inside init rather than passed as the top-level signal field, and no auditContext is provided to identify this call site in SSRF audit logs. Consider moving signal to the top level and adding auditContext: "openai-tts" for consistency and safety.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tts/tts-core.ts
Line: 668-688
Comment:
**Same `signal`-in-`init` / missing `auditContext` concern**
Same issue as in `elevenLabsTTS` above: `signal: controller.signal` is buried inside `init` rather than passed as the top-level `signal` field, and no `auditContext` is provided to identify this call site in SSRF audit logs. Consider moving `signal` to the top level and adding `auditContext: "openai-tts"` for consistency and safety.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6fbc4c209a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| withStrictGuardedFetchMode({ | ||
| url: `${baseUrl}/audio/speech`, |
There was a problem hiding this comment.
Preserve custom TTS endpoints on private networks
Using withStrictGuardedFetchMode here makes every non-public baseUrl fail SSRF checks, so OpenAI-compatible TTS backends on localhost/RFC1918 (which this file explicitly supports via OPENAI_TTS_BASE_URL, e.g. http://localhost:8880/v1) now fail at runtime with SsrFBlockedError. This is a regression from the previous behavior and breaks documented self-hosted/local TTS setups unless a private-network policy escape hatch is added for trusted operator-configured endpoints.
Useful? React with 👍 / 👎.
|
Closing this as implemented after Codex review. Current What I checked:
So I’m closing this as already implemented rather than keeping a duplicate issue open. Review notes: reviewed against 0bef73d151bd. |
Vulnerability: TTS Provider BaseUrl SSRF with API Key Exfiltration
Severity: High (CVSS 7.7)
Root Cause
ElevenLabs and OpenAI TTS functions in
tts-core.tsuse rawfetch()with user-controlledbaseUrlfrom configuration, without SSRF validation. An attacker withoperator.adminscope can setbaseUrlto an attacker-controlled server or internal network endpoint (AWS IMDS 169.254.169.254, Redis, K8s API), causing API keys (xi-api-key,Authorization: Bearer) to be leaked in request headers.Fix
Replace raw
fetch()calls in bothelevenLabsTTS()andopenaiTTS()withfetchWithSsrFGuard(withStrictGuardedFetchMode(...)), which enforces DNS pinning and blocks requests to private/internal IP ranges.Affected File
src/tts/tts-core.ts