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; }