From 96404bfc739ce98513595c6af539a7eaacfd02df Mon Sep 17 00:00:00 2001 From: Dustin Healy Date: Thu, 6 Aug 2026 06:06:04 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20SSRF-Guard=20Spe?= =?UTF-8?q?ech=20(STT/TTS)=20and=20OCR=20Outbound=20Requests=20at=20Connec?= =?UTF-8?q?t=20Time=20(#14560)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: SSRF-guard speech (STT/TTS) and OCR outbound requests at connect 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. * fix: block literal private-IP hosts and thread STT exemptions through 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. * fix: derive effective SSRF port for literal IPs and validate OCR target 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. * fix: block literal private IPs before the proxy return, destroy OCR stream on failure, canonicalize IPv6 exemptions Move the literal-IP check above the proxy/agent early return so a literal private IP is rejected even when a proxy is configured; document that a forward proxy must be SSRF-enforcing. Wrap the Mistral upload post in try/finally and destroy the file stream, so an async connect-time block does not leak the descriptor. Canonicalize IP literals in normalizeAddressCandidate through the same URL serialization targets use, so IPv4-mapped and expanded IPv6 exemptions match. --- api/server/services/Files/Audio/STTService.js | 18 +- api/server/services/Files/Audio/TTSService.js | 44 +++-- .../services/Files/Audio/speechSSRF.spec.js | 91 ++++++++++ librechat.example.yaml | 35 +++- packages/api/src/auth/agent.spec.ts | 167 +++++++++++++++++- packages/api/src/auth/agent.ts | 54 ++++++ packages/api/src/auth/allowedAddresses.ts | 22 ++- packages/api/src/files/audio.spec.ts | 34 ++++ packages/api/src/files/audio.ts | 9 +- packages/api/src/files/mistral/crud.spec.ts | 161 +++++++++++++---- packages/api/src/files/mistral/crud.ts | 44 +++-- packages/api/src/types/files.ts | 5 +- 12 files changed, 616 insertions(+), 68 deletions(-) create mode 100644 api/server/services/Files/Audio/speechSSRF.spec.js create mode 100644 packages/api/src/files/audio.spec.ts diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index c5124ea7cf..bf7b3cc004 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -8,6 +8,7 @@ const { logAxiosError, applyAxiosProxyConfig, resolveConfigSecret, + applySSRFSafeAgentIfDirect, } = require('@librechat/api'); const { extractEnvVariable, STTProviders } = require('librechat-data-provider'); const { getAppConfig } = require('~/server/services/Config'); @@ -138,7 +139,7 @@ class STTService { /** * Retrieves the configured STT provider and its schema. * @param {ServerRequest} req - The request object. - * @returns {Promise<[string, Object]>} A promise that resolves to an array containing the provider name and its schema. + * @returns {Promise<[string, Object, (string[]|undefined)]>} A promise that resolves to the provider name, its schema, and the section-level allowedAddresses exemption list. * @throws {Error} If no STT schema is set, multiple providers are set, or no provider is set. */ async getProviderSchema(req) { @@ -169,7 +170,7 @@ class STTService { } const [provider, schema] = providers[0]; - return [provider, schema]; + return [provider, schema, sttSchema.allowedAddresses]; } /** @@ -283,10 +284,11 @@ class STTService { * @param {Buffer} requestData.audioBuffer - The audio data to be transcribed. * @param {Object} requestData.audioFile - The audio file object containing originalname, mimetype, and size. * @param {string} requestData.language - The language code for the transcription. + * @param {string[]} [allowedAddresses] - Section-level SSRF exemption list of host:port pairs. * @returns {Promise} A promise that resolves to the transcribed text. * @throws {Error} If the provider is invalid, the response status is not 200, or the response data is missing. */ - async sttRequest(provider, sttSchema, { audioBuffer, audioFile, language }) { + async sttRequest(provider, sttSchema, { audioBuffer, audioFile, language }, allowedAddresses) { const strategy = this.providerStrategies[provider]; if (!strategy) { throw new Error('Invalid provider'); @@ -308,6 +310,7 @@ class STTService { const options = { headers }; applyAxiosProxyConfig(options, url); + applySSRFSafeAgentIfDirect(options, url, allowedAddresses); try { const response = await axios.post(url, data, options); @@ -347,9 +350,14 @@ class STTService { }; try { - const [provider, sttSchema] = await this.getProviderSchema(req); + const [provider, sttSchema, allowedAddresses] = await this.getProviderSchema(req); const language = req.body?.language || ''; - const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language }); + const text = await this.sttRequest( + provider, + sttSchema, + { audioBuffer, audioFile, language }, + allowedAddresses, + ); res.json({ text }); } catch (error) { logAxiosError({ message: 'An error occurred while processing the audio:', error }); diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index 58ac157db5..bace395504 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -5,6 +5,7 @@ const { logAxiosError, applyAxiosProxyConfig, resolveConfigSecret, + applySSRFSafeAgentIfDirect, } = require('@librechat/api'); const { extractEnvVariable, TTSProviders } = require('librechat-data-provider'); const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); @@ -255,10 +256,11 @@ class TTSService { * @param {string} options.input - The input text. * @param {string} options.voice - The voice to use. * @param {boolean} [options.stream=true] - Whether to use streaming. + * @param {string[]} [allowedAddresses] - Section-level SSRF exemption list of host:port pairs. * @returns {Promise} The axios response object. * @throws {Error} If the provider is invalid or the request fails. */ - async ttsRequest(provider, ttsSchema, { input, voice, stream = true }) { + async ttsRequest(provider, ttsSchema, { input, voice, stream = true }, allowedAddresses) { const strategy = this.providerStrategies[provider]; if (!strategy) { throw new Error('Invalid provider'); @@ -271,6 +273,7 @@ class TTSService { const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' }; applyAxiosProxyConfig(options, url); + applySSRFSafeAgentIfDirect(options, url, allowedAddresses); try { return await axios.post(url, data, options); @@ -305,10 +308,16 @@ class TTSService { res.setHeader('Content-Type', 'audio/mpeg'); const provider = this.getProvider(appConfig); const ttsSchema = appConfig?.speech?.tts?.[provider]; + const allowedAddresses = appConfig?.speech?.tts?.allowedAddresses; const voice = await this.getVoice(ttsSchema, requestVoice); if (input.length < 4096) { - const response = await this.ttsRequest(provider, ttsSchema, { input, voice }); + const response = await this.ttsRequest( + provider, + ttsSchema, + { input, voice }, + allowedAddresses, + ); response.data.pipe(res); return; } @@ -317,11 +326,16 @@ class TTSService { for (const chunk of textChunks) { try { - const response = await this.ttsRequest(provider, ttsSchema, { - voice, - input: chunk.text, - stream: true, - }); + const response = await this.ttsRequest( + provider, + ttsSchema, + { + voice, + input: chunk.text, + stream: true, + }, + allowedAddresses, + ); logger.debug(`[textToSpeech] user: ${req?.user?.id} | writing audio stream`); await new Promise((resolve) => { @@ -373,6 +387,7 @@ class TTSService { })); const provider = this.getProvider(appConfig); const ttsSchema = appConfig?.speech?.tts?.[provider]; + const allowedAddresses = appConfig?.speech?.tts?.allowedAddresses; const voice = await this.getVoice(ttsSchema, req.body.voice); let shouldContinue = true; @@ -399,11 +414,16 @@ class TTSService { for (const update of updates) { try { - const response = await this.ttsRequest(provider, ttsSchema, { - voice, - input: update.text, - stream: true, - }); + const response = await this.ttsRequest( + provider, + ttsSchema, + { + voice, + input: update.text, + stream: true, + }, + allowedAddresses, + ); if (!shouldContinue) { break; diff --git a/api/server/services/Files/Audio/speechSSRF.spec.js b/api/server/services/Files/Audio/speechSSRF.spec.js new file mode 100644 index 0000000000..3279dc69bf --- /dev/null +++ b/api/server/services/Files/Audio/speechSSRF.spec.js @@ -0,0 +1,91 @@ +jest.mock('axios'); +jest.mock('@librechat/data-schemas', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); +jest.mock('./streamAudio', () => ({ + getRandomVoiceId: jest.fn(), + createChunkProcessor: jest.fn(), + splitTextIntoChunks: jest.fn(), +})); + +const axios = require('axios'); +const { STTService } = require('./STTService'); +const { textToSpeech } = require('./TTSService'); + +afterEach(() => { + jest.restoreAllMocks(); + axios.post.mockReset(); +}); + +describe('STT sttRequest SSRF guard (real agent)', () => { + const audioFile = { originalname: 'a.wav', mimetype: 'audio/wav', size: 1 }; + const audioBuffer = Buffer.from('audio'); + const provider = { url: 'http://10.0.0.5:8080', apiKey: 'sk', model: 'whisper-1' }; + + it('blocks a private-IP provider url with ESSRF before any request goes out', async () => { + const service = new STTService(); + await expect( + service.sttRequest('openai', provider, { audioBuffer, audioFile, language: '' }), + ).rejects.toMatchObject({ code: 'ESSRF' }); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('exempts a host:port in the section allowedAddresses and sets maxRedirects 0 with agents', async () => { + axios.post.mockResolvedValue({ status: 200, data: { text: 'ok' } }); + const service = new STTService(); + + await service.sttRequest('openai', provider, { audioBuffer, audioFile, language: '' }, [ + '10.0.0.5:8080', + ]); + + const options = axios.post.mock.calls[0][2]; + expect(options.maxRedirects).toBe(0); + expect(options.httpAgent).toBeDefined(); + expect(options.httpsAgent).toBeDefined(); + }); +}); + +describe('TTS textToSpeech SSRF guard (real agent)', () => { + function buildReqRes(allowedAddresses) { + const req = { + body: { input: 'hi', voice: 'v1' }, + user: { id: 'u1' }, + config: { + speech: { + tts: { + ...(allowedAddresses ? { allowedAddresses } : {}), + localai: { url: 'http://10.0.0.5:8080', apiKey: 'sk', voices: ['v1'] }, + }, + }, + }, + }; + const res = { + setHeader: jest.fn(), + headersSent: false, + status: jest.fn(() => ({ end: jest.fn(), send: jest.fn() })), + end: jest.fn(), + }; + return { req, res }; + } + + it('blocks a private-IP provider url and never issues the outbound request', async () => { + const { req, res } = buildReqRes(); + + await textToSpeech(req, res); + + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('exempts a host:port in the section allowedAddresses and sets maxRedirects 0 with agents', async () => { + axios.post.mockResolvedValue({ status: 200, data: { pipe: jest.fn(), on: jest.fn() } }); + const { req, res } = buildReqRes(['10.0.0.5:8080']); + + await textToSpeech(req, res); + + const options = axios.post.mock.calls[0][2]; + expect(options.maxRedirects).toBe(0); + expect(options.httpAgent).toBeDefined(); + expect(options.httpsAgent).toBeDefined(); + }); +}); diff --git a/librechat.example.yaml b/librechat.example.yaml index 60e5c952c2..b8f6ce4bbb 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -257,6 +257,20 @@ registration: # Note: If balance.enabled is true, transactions will always be enabled # regardless of this setting to ensure balance tracking works correctly +# Speech (STT/TTS) outbound requests to operator-provided target URLs are SSRF-guarded +# at connect time: private, loopback, link-local, and cloud-metadata targets are blocked +# by default. To point STT/TTS at a private or self-hosted service (LocalAI, a self-hosted +# Whisper server), add its host:port to `allowedAddresses` on the `stt` / `tts` section. +# SECURITY: `allowedAddresses` entries are trusted before the private-IP check. A listed +# host:port is permitted even when it resolves to a private IP, so list only hosts you fully +# control and that cannot be repointed by an attacker. Do not list attacker-controllable or +# DNS-rebindable hostnames, because doing so re-opens the private-address path this guard +# closes. Prefer a private IP literal over a hostname when exempting a private target. Entries +# must include a port (`host:port`, `private.ip:port`, or `[ipv6]:port`); do not use URLs, paths, +# CIDR ranges, bare hosts/IPs, or public IP literals. +# When a forward proxy is configured (PROXY / HTTP(S)_PROXY), it performs DNS and egress in its +# own network context, so these requests are delegated to it and it must be SSRF-enforcing; the +# connect-time guard only covers direct, non-proxied connections. # speech: # tts: # openai: @@ -264,13 +278,32 @@ registration: # apiKey: '${TTS_API_KEY}' # model: '' # voices: [''] - +# allowedAddresses: +# - 'localhost:8020' +# - '127.0.0.1:8020' # # stt: # openai: # url: '' # apiKey: '${STT_API_KEY}' # model: '' +# allowedAddresses: +# - 'localhost:8000' +# - '127.0.0.1:8000' + +# OCR (Mistral / Mistral-compatible) outbound requests to `ocr.baseURL` are SSRF-guarded at +# connect time with the same default-deny for private targets. To point OCR at a private or +# self-hosted Mistral-compatible service, add its host:port to `allowedAddresses`. The same +# trust caveat as speech applies: a listed host:port is trusted before the private-IP check, +# so list only hosts you fully control and that cannot be repointed by an attacker. Entries +# must include a port and must not be URLs, paths, CIDR ranges, bare hosts/IPs, or public IP +# literals. +# ocr: +# baseURL: '${OCR_BASEURL}' +# apiKey: '${OCR_API_KEY}' +# allowedAddresses: +# - 'localhost:8080' +# - '127.0.0.1:8080' # rateLimits: # fileUploads: diff --git a/packages/api/src/auth/agent.spec.ts b/packages/api/src/auth/agent.spec.ts index f74542c6c8..e9a081767d 100644 --- a/packages/api/src/auth/agent.spec.ts +++ b/packages/api/src/auth/agent.spec.ts @@ -8,8 +8,13 @@ jest.mock('node:dns', () => { import dns from 'node:dns'; import http from 'node:http'; +import type { AxiosRequestConfig } from 'axios'; import type { LookupFunction } from 'node:net'; -import { createSSRFSafeAgents, createSSRFSafeUndiciConnect } from './agent'; +import { + createSSRFSafeAgents, + createSSRFSafeUndiciConnect, + applySSRFSafeAgentIfDirect, +} from './agent'; type LookupCallback = ( err: NodeJS.ErrnoException | null, @@ -330,3 +335,163 @@ describe('SSRF agents — allowedAddresses exemption', () => { expect(result.err!.code).toBe('ESSRF'); }); }); + +describe('applySSRFSafeAgentIfDirect', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + function spyLookup(hostname: string, port: number) { + const captured: { err: NodeJS.ErrnoException | null; address: string } = { + err: null, + address: '', + }; + jest.spyOn(httpAgentPrototype, 'createConnection').mockImplementation((( + options: Record, + ) => { + (options.lookup as LookupFunction)(hostname, {}, (err, address) => { + captured.err = err; + captured.address = address as string; + }); + return {}; + }) as never); + return { + drive(agent: unknown) { + (agent as { createConnection: (o: Record) => unknown }).createConnection({ + host: hostname, + port, + }); + return captured; + }, + }; + } + + it('attaches both agents and disables redirects for a direct http(s) request', () => { + const config: AxiosRequestConfig = {}; + applySSRFSafeAgentIfDirect(config, 'https://api.example.com/v1'); + expect(config.httpAgent).toBeDefined(); + expect(config.httpsAgent).toBeDefined(); + expect(config.maxRedirects).toBe(0); + }); + + it('rejects a target resolving to a private IP with ESSRF through the real lookup', () => { + mockDnsResult('10.0.0.5', 4); + const probe = spyLookup('internal.example.com', 80); + const config: AxiosRequestConfig = {}; + applySSRFSafeAgentIfDirect(config, 'http://internal.example.com'); + const result = probe.drive(config.httpAgent); + expect(result.err).toBeTruthy(); + expect(result.err!.code).toBe('ESSRF'); + }); + + it('exempts a host:port present in allowedAddresses through the real lookup', () => { + mockDnsResult('10.0.0.5', 4); + const probe = spyLookup('ollama.internal', 11434); + const config: AxiosRequestConfig = {}; + applySSRFSafeAgentIfDirect(config, 'http://ollama.internal:11434', ['ollama.internal:11434']); + const result = probe.drive(config.httpAgent); + expect(result.err).toBeNull(); + expect(result.address).toBe('10.0.0.5'); + }); + + it('throws on a non-http(s) scheme', () => { + expect(() => applySSRFSafeAgentIfDirect({}, 'file:///etc/passwd')).toThrow(); + expect(() => applySSRFSafeAgentIfDirect({}, 'gopher://example.com')).toThrow(); + }); + + it('throws on a malformed url', () => { + expect(() => applySSRFSafeAgentIfDirect({}, 'not a url')).toThrow(); + }); + + it('preserves an existing proxy and still disables redirects', () => { + const config: AxiosRequestConfig = { proxy: { host: '127.0.0.1', port: 8080 } }; + applySSRFSafeAgentIfDirect(config, 'https://api.example.com'); + expect(config.httpAgent).toBeUndefined(); + expect(config.httpsAgent).toBeUndefined(); + expect(config.maxRedirects).toBe(0); + }); + + it('preserves a pre-set agent and still disables redirects', () => { + const preset = new http.Agent(); + const config: AxiosRequestConfig = { httpAgent: preset }; + applySSRFSafeAgentIfDirect(config, 'https://api.example.com'); + expect(config.httpAgent).toBe(preset); + expect(config.httpsAgent).toBeUndefined(); + expect(config.maxRedirects).toBe(0); + }); + + it('blocks a literal private IPv4 host that skips the agent DNS lookup', () => { + let code: string | undefined; + try { + applySSRFSafeAgentIfDirect({}, 'http://127.0.0.1:9000'); + } catch (err) { + code = (err as NodeJS.ErrnoException).code; + } + expect(code).toBe('ESSRF'); + }); + + it('blocks a literal private IPv6 host', () => { + let code: string | undefined; + try { + applySSRFSafeAgentIfDirect({}, 'http://[::1]:9000'); + } catch (err) { + code = (err as NodeJS.ErrnoException).code; + } + expect(code).toBe('ESSRF'); + }); + + it('exempts a literal private IP present in allowedAddresses', () => { + const config: AxiosRequestConfig = {}; + applySSRFSafeAgentIfDirect(config, 'http://127.0.0.1:9000', ['127.0.0.1:9000']); + expect(config.httpAgent).toBeDefined(); + expect(config.maxRedirects).toBe(0); + }); + + it('allows a public literal IP', () => { + const config: AxiosRequestConfig = {}; + applySSRFSafeAgentIfDirect(config, 'http://8.8.8.8:80'); + expect(config.httpAgent).toBeDefined(); + }); + + it('exempts a literal private IP on the default http port when the URL omits it', () => { + const config: AxiosRequestConfig = {}; + applySSRFSafeAgentIfDirect(config, 'http://127.0.0.1', ['127.0.0.1:80']); + expect(config.httpAgent).toBeDefined(); + }); + + it('exempts a literal private IP on the default https port when the URL omits it', () => { + const config: AxiosRequestConfig = {}; + applySSRFSafeAgentIfDirect(config, 'https://127.0.0.1', ['127.0.0.1:443']); + expect(config.httpsAgent).toBeDefined(); + }); + + it('blocks a literal private IP even when a proxy is already configured', () => { + let code: string | undefined; + try { + applySSRFSafeAgentIfDirect( + { proxy: { host: '127.0.0.1', port: 8080 } }, + 'http://169.254.169.254', + ); + } catch (err) { + code = (err as NodeJS.ErrnoException).code; + } + expect(code).toBe('ESSRF'); + }); + + it('exempts an IPv4-mapped IPv6 literal listed in allowedAddresses', () => { + const config: AxiosRequestConfig = {}; + applySSRFSafeAgentIfDirect(config, 'http://[::ffff:127.0.0.1]:8080', [ + '[::ffff:127.0.0.1]:8080', + ]); + expect(config.httpAgent).toBeDefined(); + }); + + it('exempts a fully expanded ULA literal listed in allowedAddresses', () => { + const config: AxiosRequestConfig = {}; + applySSRFSafeAgentIfDirect(config, 'http://[fd00:0:0:0:0:0:0:1]:8080', [ + '[fd00:0:0:0:0:0:0:1]:8080', + ]); + expect(config.httpAgent).toBeDefined(); + }); +}); diff --git a/packages/api/src/auth/agent.ts b/packages/api/src/auth/agent.ts index 7222d8de64..2ef15be693 100644 --- a/packages/api/src/auth/agent.ts +++ b/packages/api/src/auth/agent.ts @@ -1,6 +1,8 @@ import dns from 'node:dns'; import http from 'node:http'; import https from 'node:https'; +import { isIP } from 'node:net'; +import type { AxiosRequestConfig } from 'axios'; import type { LookupFunction } from 'node:net'; import { normalizePort, @@ -142,3 +144,55 @@ export function createSSRFSafeUndiciConnect( : ssrfSafeLookup; return { lookup }; } + +/** + * Attaches SSRF-safe HTTP(S) agents to an axios config for a direct request. + * Rejects non-http(s) (and unparseable) target urls, since the agents validate + * the resolved IP at connect time but never inspect the scheme. Sets + * `maxRedirects: 0` unconditionally so a redirect cannot bypass that check, and + * leaves the agents untouched when a proxy or agent is already set. + * + * @param config - The axios request config to mutate. + * @param url - The request target URL (http/https only). + * @param allowedAddresses - Optional admin exemption list of host:port pairs. + */ +export function applySSRFSafeAgentIfDirect( + config: AxiosRequestConfig, + url: string, + allowedAddresses?: string[] | null, +): AxiosRequestConfig { + const { protocol, hostname, port } = new URL(url); + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error(`Unsupported URL scheme for SSRF-guarded request: ${protocol}`); + } + + config.maxRedirects = 0; + + // Node skips the agent's custom DNS lookup for IP-literal hosts, and a configured proxy + // connects on our behalf without running that check, so a literal private IP (e.g. + // http://169.254.169.254) must be rejected before any proxy or agent early return. + const literalHost = hostname.replace(/^\[|\]$/g, ''); + if (isIP(literalHost)) { + const exemptSet = normalizeAllowedAddressesSet(allowedAddresses); + const normalizedPort = normalizePort(port || (protocol === 'https:' ? '443' : '80')); + const hostnameAllowed = isAddressInAllowedSet(literalHost, exemptSet, normalizedPort); + const blockedAddress = getBlockedLookupAddress( + literalHost, + hostnameAllowed, + exemptSet, + normalizedPort, + ); + if (blockedAddress) { + throw createSSRFLookupError(literalHost, blockedAddress); + } + } + + if (config.httpsAgent || config.httpAgent || config.proxy) { + return config; + } + + const { httpAgent, httpsAgent } = createSSRFSafeAgents(allowedAddresses); + config.httpAgent = httpAgent; + config.httpsAgent = httpsAgent; + return config; +} diff --git a/packages/api/src/auth/allowedAddresses.ts b/packages/api/src/auth/allowedAddresses.ts index 01b8ff0e4b..2b701ac44b 100644 --- a/packages/api/src/auth/allowedAddresses.ts +++ b/packages/api/src/auth/allowedAddresses.ts @@ -16,6 +16,7 @@ * status. Hostnames pass through; their resolved IP is checked * separately by callers (e.g. `resolveHostnameSSRF`). */ +import { isIP } from 'node:net'; import { isPrivateIP } from './ip'; const ADDRESS_PORT_SEPARATOR = '\0'; @@ -58,13 +59,32 @@ function addressPortKey(address: string, port: string): string { return `${address}${ADDRESS_PORT_SEPARATOR}${port}`; } +/** + * Canonicalizes an IP literal to WHATWG-URL form so allowlist entries and targets (which pass + * through `new URL`) compare identically, covering IPv4-mapped and expanded/ULA IPv6 forms. + */ +function canonicalizeIPLiteral(value: string): string { + const family = isIP(value); + if (family === 0) return value; + try { + const host = family === 6 ? `[${value}]` : value; + return new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, ''); + } catch { + return value; + } +} + function normalizeAddressCandidate(candidate: string): string { const normalized = candidate .toLowerCase() .trim() .replace(/^\[|\]$/g, ''); if (!normalized) return ''; - if (isIPLiteral(normalized) && !isPrivateIP(normalized)) return ''; + if (isIPLiteral(normalized)) { + const canonical = canonicalizeIPLiteral(normalized); + if (!isPrivateIP(canonical)) return ''; + return canonical; + } return normalized; } diff --git a/packages/api/src/files/audio.spec.ts b/packages/api/src/files/audio.spec.ts new file mode 100644 index 0000000000..93b044047f --- /dev/null +++ b/packages/api/src/files/audio.spec.ts @@ -0,0 +1,34 @@ +import fs from 'fs'; +import type { STTService, ServerRequest, FileObject } from '../types'; +import { processAudioFile } from './audio'; + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { ...actual, promises: { ...actual.promises, readFile: jest.fn() } }; +}); + +describe('processAudioFile', () => { + it('threads the section-level allowedAddresses from getProviderSchema into sttRequest', async () => { + (fs.promises.readFile as jest.Mock).mockResolvedValue(Buffer.from('audio')); + const sttRequest = jest.fn().mockResolvedValue('transcribed'); + const schema = { url: 'http://stt.internal:8020' }; + const sttService: STTService = { + getInstance: jest.fn(), + getProviderSchema: jest.fn().mockResolvedValue(['openai', schema, ['stt.internal:8020']]), + sttRequest, + }; + const file: FileObject = { + path: '/tmp/a.wav', + originalname: 'a.wav', + mimetype: 'audio/wav', + size: 5, + }; + + const result = await processAudioFile({ req: {} as ServerRequest, file, sttService }); + + expect(result.text).toBe('transcribed'); + expect(sttRequest).toHaveBeenCalledWith('openai', schema, expect.any(Object), [ + 'stt.internal:8020', + ]); + }); +}); diff --git a/packages/api/src/files/audio.ts b/packages/api/src/files/audio.ts index a2d93c1002..01d5de6714 100644 --- a/packages/api/src/files/audio.ts +++ b/packages/api/src/files/audio.ts @@ -29,8 +29,13 @@ export async function processAudioFile({ size: file.size, }; - const [provider, sttSchema] = await sttService.getProviderSchema(req); - const text = await sttService.sttRequest(provider, sttSchema, { audioBuffer, audioFile }); + const [provider, sttSchema, allowedAddresses] = await sttService.getProviderSchema(req); + const text = await sttService.sttRequest( + provider, + sttSchema, + { audioBuffer, audioFile }, + allowedAddresses, + ); return { text, diff --git a/packages/api/src/files/mistral/crud.spec.ts b/packages/api/src/files/mistral/crud.spec.ts index ecac188091..33b2f9b3d1 100644 --- a/packages/api/src/files/mistral/crud.spec.ts +++ b/packages/api/src/files/mistral/crud.spec.ts @@ -139,6 +139,18 @@ describe('MistralOCR Service', () => { (jest.mocked(fs).createReadStream as jest.Mock).mockReturnValue(mockReadStream); }); + it('destroys the upload stream when the request fails (async SSRF block)', async () => { + const err = Object.assign(new Error('SSRF protection'), { code: 'ESSRF' }); + mockAxios.post!.mockRejectedValueOnce(err); + + await expect( + uploadDocumentToMistral({ filePath: '/path/to/test.pdf', apiKey: 'k' }), + ).rejects.toBe(err); + + const stream = (jest.mocked(fs).createReadStream as jest.Mock).mock.results[0].value; + expect(stream.destroy).toHaveBeenCalled(); + }); + it('should upload a document to Mistral API using file streaming', async () => { const mockResponse: { data: MistralFileUploadResponse } = { data: { @@ -172,6 +184,9 @@ describe('MistralOCR Service', () => { }), maxBodyLength: Infinity, maxContentLength: Infinity, + maxRedirects: 0, + httpAgent: expect.anything(), + httpsAgent: expect.anything(), }), ); expect(result).toEqual(mockResponse.data); @@ -212,11 +227,14 @@ describe('MistralOCR Service', () => { expect(mockAxios.get).toHaveBeenCalledWith( 'https://api.mistral.ai/v1/files/file-123/url?expiry=24', - { + expect.objectContaining({ headers: { Authorization: 'Bearer test-api-key', }, - }, + maxRedirects: 0, + httpAgent: expect.anything(), + httpsAgent: expect.anything(), + }), ); expect(result).toEqual(mockResponse.data); }); @@ -246,11 +264,17 @@ describe('MistralOCR Service', () => { baseURL: 'https://api.mistral.ai/v1', }); - expect(mockAxios.delete).toHaveBeenCalledWith('https://api.mistral.ai/v1/files/file-123', { - headers: { - Authorization: 'Bearer test-api-key', - }, - }); + expect(mockAxios.delete).toHaveBeenCalledWith( + 'https://api.mistral.ai/v1/files/file-123', + expect.objectContaining({ + headers: { + Authorization: 'Bearer test-api-key', + }, + maxRedirects: 0, + httpAgent: expect.anything(), + httpsAgent: expect.anything(), + }), + ); }); it('should use default baseURL when not provided', async () => { @@ -261,11 +285,14 @@ describe('MistralOCR Service', () => { apiKey: 'test-api-key', }); - expect(mockAxios.delete).toHaveBeenCalledWith('https://api.mistral.ai/v1/files/file-456', { - headers: { - Authorization: 'Bearer test-api-key', - }, - }); + expect(mockAxios.delete).toHaveBeenCalledWith( + 'https://api.mistral.ai/v1/files/file-456', + expect.objectContaining({ + headers: { + Authorization: 'Bearer test-api-key', + }, + }), + ); }); it('should not throw when deletion fails', async () => { @@ -332,12 +359,15 @@ describe('MistralOCR Service', () => { document_url: 'https://document-url.com', }, }, - { + expect.objectContaining({ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-api-key', }, - }, + maxRedirects: 0, + httpAgent: expect.anything(), + httpsAgent: expect.anything(), + }), ); expect(result).toEqual(mockResponse.data); }); @@ -381,12 +411,12 @@ describe('MistralOCR Service', () => { image_url: 'https://image-url.com/image.png', }, }, - { + expect.objectContaining({ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-api-key', }, - }, + }), ); expect(result).toEqual(mockResponse.data); }); @@ -858,8 +888,8 @@ describe('MistralOCR Service', () => { user: { id: 'user123' }, config: { ocr: { - apiKey: 'OCR_API_KEY', - baseURL: 'OCR_BASEURL', + apiKey: '${OCR_API_KEY}', + baseURL: '${OCR_BASEURL}', }, }, } as unknown as ServerRequest; @@ -942,8 +972,8 @@ describe('MistralOCR Service', () => { user: { id: 'user123' }, config: { ocr: { - apiKey: 'OCR_API_KEY', - baseURL: 'OCR_BASEURL', + apiKey: '${OCR_API_KEY}', + baseURL: '${OCR_BASEURL}', mistralModel: 'mistral-ocr-latest', }, }, @@ -1603,11 +1633,11 @@ describe('MistralOCR Service', () => { // Verify delete was called with correct parameters expect(mockAxios.delete).toHaveBeenCalledWith( 'https://api.mistral.ai/v1/files/file-cleanup-123', - { + expect.objectContaining({ headers: { Authorization: 'Bearer test-api-key', }, - }, + }), ); expect(mockAxios.delete).toHaveBeenCalledTimes(1); }); @@ -1672,11 +1702,11 @@ describe('MistralOCR Service', () => { // Verify delete was still called despite the error expect(mockAxios.delete).toHaveBeenCalledWith( 'https://api.mistral.ai/v1/files/file-cleanup-456', - { + expect.objectContaining({ headers: { Authorization: 'Bearer test-api-key', }, - }, + }), ); expect(mockAxios.delete).toHaveBeenCalledTimes(1); }); @@ -1765,11 +1795,11 @@ describe('MistralOCR Service', () => { // Verify delete was attempted expect(mockAxios.delete).toHaveBeenCalledWith( 'https://api.mistral.ai/v1/files/file-cleanup-789', - { + expect.objectContaining({ headers: { Authorization: 'Bearer test-api-key', }, - }, + }), ); // Verify error was logged @@ -2015,13 +2045,13 @@ describe('MistralOCR Service', () => { apiKey: 'test-api-key', }); - expect(mockAxios.post).toHaveBeenCalledWith( - 'https://api.mistral.ai/v1/files', - expect.anything(), - expect.not.objectContaining({ - httpsAgent: expect.anything(), - }), - ); + const config = mockAxios.post!.mock.calls[0][2] as { + maxRedirects?: number; + httpsAgent?: { proxyUrl?: string }; + }; + expect(config.maxRedirects).toBe(0); + expect(config.httpsAgent).toBeDefined(); + expect(config.httpsAgent?.proxyUrl).toBeUndefined(); }); }); @@ -2459,4 +2489,69 @@ describe('MistralOCR Service', () => { }); }); }); + + describe('SSRF connect-time guard', () => { + afterEach(() => { + jest.restoreAllMocks(); + delete process.env.PROXY; + }); + + it('blocks a literal private-IP baseURL with ESSRF', async () => { + await expect( + performOCR({ + apiKey: 'k', + url: 'https://document-url.com', + baseURL: 'http://10.0.0.5:8080', + }), + ).rejects.toMatchObject({ code: 'ESSRF' }); + }); + + it('exempts a literal private IP present in ocr.allowedAddresses', async () => { + mockAxios.post!.mockResolvedValueOnce({ data: { pages: [] } }); + await performOCR({ + apiKey: 'k', + url: 'https://document-url.com', + baseURL: 'http://10.0.0.5:8080', + allowedAddresses: ['10.0.0.5:8080'], + }); + + const config = mockAxios.post!.mock.calls[0][2] as { + httpAgent?: unknown; + maxRedirects?: number; + }; + expect(config.httpAgent).toBeDefined(); + expect(config.maxRedirects).toBe(0); + }); + + it('does not open the upload file stream when the OCR target is a blocked literal private IP', async () => { + (jest.mocked(fs).createReadStream as jest.Mock).mockClear(); + await expect( + uploadDocumentToMistral({ + apiKey: 'k', + filePath: '/tmp/doc.pdf', + baseURL: 'http://10.0.0.5:8080', + }), + ).rejects.toMatchObject({ code: 'ESSRF' }); + expect(jest.mocked(fs).createReadStream).not.toHaveBeenCalled(); + }); + + it('preserves proxy precedence and still disables redirects', async () => { + process.env.PROXY = 'http://proxy.example.com:8080'; + mockAxios.post!.mockResolvedValueOnce({ data: { pages: [] } }); + await performOCR({ + apiKey: 'k', + url: 'https://document-url.com', + baseURL: 'https://api.mistral.ai/v1', + }); + + const config = mockAxios.post!.mock.calls[0][2] as { + maxRedirects?: number; + httpsAgent?: { proxyUrl?: string }; + httpAgent?: unknown; + }; + expect(config.maxRedirects).toBe(0); + expect(config.httpsAgent?.proxyUrl).toBe('http://proxy.example.com:8080'); + expect(config.httpAgent).toBeUndefined(); + }); + }); }); diff --git a/packages/api/src/files/mistral/crud.ts b/packages/api/src/files/mistral/crud.ts index 5af1ef90a9..463faa4d51 100644 --- a/packages/api/src/files/mistral/crud.ts +++ b/packages/api/src/files/mistral/crud.ts @@ -22,6 +22,7 @@ import type { } from '~/types'; import { decryptConfigSecret, isEncryptedSecretPayload } from '~/admin/secrets'; import { logAxiosError, createAxiosInstance } from '~/utils/axios'; +import { applySSRFSafeAgentIfDirect } from '~/auth/agent'; import { applyAxiosProxyConfig } from '~/utils/proxy'; import { readFileAsBuffer } from '~/utils/files'; import { loadServiceKey } from '~/utils/key'; @@ -68,17 +69,17 @@ export async function uploadDocumentToMistral({ filePath, baseURL = DEFAULT_MISTRAL_BASE_URL, fileName = '', + allowedAddresses, }: { apiKey: string; filePath: string; baseURL?: string; fileName?: string; + allowedAddresses?: string[] | null; }): Promise { const form = new FormData(); form.append('purpose', 'ocr'); const actualFileName = fileName || path.basename(filePath); - const fileStream = fs.createReadStream(filePath); - form.append('file', fileStream, { filename: actualFileName }); const config: AxiosRequestConfig = { headers: { @@ -90,13 +91,17 @@ export async function uploadDocumentToMistral({ }; applyAxiosProxyConfig(config, `${baseURL}/files`); + applySSRFSafeAgentIfDirect(config, `${baseURL}/files`, allowedAddresses); - return axios - .post(`${baseURL}/files`, form, config) - .then((res) => res.data) - .catch((error) => { - throw error; - }); + const fileStream = fs.createReadStream(filePath); + form.append('file', fileStream, { filename: actualFileName }); + + try { + const response = await axios.post(`${baseURL}/files`, form, config); + return response.data; + } finally { + fileStream.destroy(); + } } export async function getSignedUrl({ @@ -104,11 +109,13 @@ export async function getSignedUrl({ fileId, expiry = 24, baseURL = DEFAULT_MISTRAL_BASE_URL, + allowedAddresses, }: { apiKey: string; fileId: string; expiry?: number; baseURL?: string; + allowedAddresses?: string[] | null; }): Promise { const config: AxiosRequestConfig = { headers: { @@ -116,7 +123,9 @@ export async function getSignedUrl({ }, }; - applyAxiosProxyConfig(config, `${baseURL}/files/${fileId}/url?expiry=${expiry}`); + const signedUrlTarget = `${baseURL}/files/${fileId}/url?expiry=${expiry}`; + applyAxiosProxyConfig(config, signedUrlTarget); + applySSRFSafeAgentIfDirect(config, signedUrlTarget, allowedAddresses); return axios .get(`${baseURL}/files/${fileId}/url?expiry=${expiry}`, config) @@ -142,12 +151,14 @@ export async function performOCR({ model = DEFAULT_MISTRAL_MODEL, baseURL = DEFAULT_MISTRAL_BASE_URL, documentType = 'document_url', + allowedAddresses, }: { url: string; apiKey: string; model?: string; baseURL?: string; documentType?: 'document_url' | 'image_url'; + allowedAddresses?: string[] | null; }): Promise { const documentKey = documentType === 'image_url' ? 'image_url' : 'document_url'; @@ -160,6 +171,7 @@ export async function performOCR({ const ocrURL = baseURL.endsWith('/ocr') ? baseURL : `${baseURL}/ocr`; applyAxiosProxyConfig(config, ocrURL); + applySSRFSafeAgentIfDirect(config, ocrURL, allowedAddresses); return axios .post( @@ -194,10 +206,12 @@ export async function deleteMistralFile({ fileId, apiKey, baseURL = DEFAULT_MISTRAL_BASE_URL, + allowedAddresses, }: { fileId: string; apiKey: string; baseURL?: string; + allowedAddresses?: string[] | null; }): Promise { const config: AxiosRequestConfig = { headers: { @@ -206,6 +220,7 @@ export async function deleteMistralFile({ }; applyAxiosProxyConfig(config, `${baseURL}/files/${fileId}`); + applySSRFSafeAgentIfDirect(config, `${baseURL}/files/${fileId}`, allowedAddresses); try { const result = await axios.delete(`${baseURL}/files/${fileId}`, config); @@ -386,6 +401,8 @@ export const uploadMistralOCR = async (context: OCRContext): Promise; - getProviderSchema(req: ServerRequest): Promise<[string, object]>; + getProviderSchema(req: ServerRequest): Promise<[string, object, string[] | undefined]>; sttRequest( provider: string, schema: object, params: { audioBuffer: Buffer; audioFile: AudioFileInfo }, + allowedAddresses?: string[], ): Promise; }