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

* 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.
This commit is contained in:
Dustin Healy 2026-08-06 06:06:04 -07:00 committed by GitHub
parent 0e14d91ed9
commit 96404bfc73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 616 additions and 68 deletions

View file

@ -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<string>} 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 });

View file

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

View file

@ -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();
});
});

View file

@ -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:

View file

@ -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<string, unknown>,
) => {
(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<string, unknown>) => 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();
});
});

View file

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

View file

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

View file

@ -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',
]);
});
});

View file

@ -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,

View file

@ -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();
});
});
});

View file

@ -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<MistralFileUploadResponse> {
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<MistralSignedUrlResponse> {
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<OCRResult> {
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<void> {
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<MistralOCRU
let apiKey: string | undefined;
let baseURL: string | undefined;
const allowedAddresses = context.req.config?.ocr?.allowedAddresses;
try {
const authConfig = await loadAuthConfig(context);
apiKey = authConfig.apiKey;
@ -397,6 +414,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise<MistralOCRU
fileName: context.file.originalname,
apiKey,
baseURL,
allowedAddresses,
});
mistralFileId = mistralFile.id;
@ -405,6 +423,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise<MistralOCRU
apiKey,
baseURL,
fileId: mistralFile.id,
allowedAddresses,
});
const documentType = getDocumentType(context.file);
@ -414,6 +433,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise<MistralOCRU
baseURL,
apiKey,
model,
allowedAddresses,
});
if (!ocrResult || !ocrResult.pages || ocrResult.pages.length === 0) {
@ -424,7 +444,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise<MistralOCRU
const { text, images } = processOCRResult(ocrResult);
if (mistralFileId && apiKey && baseURL) {
await deleteMistralFile({ fileId: mistralFileId, apiKey, baseURL });
await deleteMistralFile({ fileId: mistralFileId, apiKey, baseURL, allowedAddresses });
}
return {
@ -436,7 +456,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise<MistralOCRU
};
} catch (error) {
if (mistralFileId && apiKey && baseURL) {
await deleteMistralFile({ fileId: mistralFileId, apiKey, baseURL });
await deleteMistralFile({ fileId: mistralFileId, apiKey, baseURL, allowedAddresses });
}
throw createOCRError(error, 'Error uploading document to Mistral OCR API:');
}
@ -461,6 +481,7 @@ export const uploadAzureMistralOCR = async (
try {
const { apiKey, baseURL } = await loadAuthConfig(context);
const model = getModelConfig(context.req.config?.ocr);
const allowedAddresses = context.req.config?.ocr?.allowedAddresses;
const { content: buffer } = await readFileAsBuffer(context.file.path, {
fileSize: context.file.size,
@ -476,6 +497,7 @@ export const uploadAzureMistralOCR = async (
model,
url: `${base64Prefix}${base64}`,
documentType,
allowedAddresses,
});
if (!ocrResult || !ocrResult.pages || ocrResult.pages.length === 0) {

View file

@ -1,15 +1,16 @@
import type { BedrockDocumentFormat } from 'librechat-data-provider';
import type { IMongoFile } from '@librechat/data-schemas';
import type { Readable } from 'stream';
import type { ServerRequest } from './http';
import type { DownloadURLParams } from '~/storage/types';
import type { ServerRequest } from './http';
export interface STTService {
getInstance(): Promise<STTService>;
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<string>;
}