From 4cec1a675f0b45123638de065108a51525ce871e Mon Sep 17 00:00:00 2001 From: Dustin Healy Date: Wed, 5 Aug 2026 10:42:43 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20feat:=20Add=20allowedAddresses?= =?UTF-8?q?=20Exemption=20to=20Speech=20(STT/TTS)=20and=20OCR=20Config=20S?= =?UTF-8?q?chemas=20(#14559)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add allowedAddresses exemption to speech (STT/TTS) and OCR config schemas Add the existing allowedAddressesSchema as an optional field on sttSchema, ttsSchema, and ocrSchema, reusing the schema already attached to endpoints, mcpSettings, and actions so port scoping and normalization stay identical. STT and TTS resolve a single provider by counting non-empty section keys, so exclude the allowedAddresses key from that scan. Without the exclusion a configured exemption list would be counted as a second provider and trip the "Multiple providers are set" guard. The field is inert on its own: nothing reads it for SSRF yet, and provider detection now ignores it. * fix: preserve allowedAddresses through the OCR config loaders loadOCRConfig rebuilt the ocr config with only apiKey, baseURL, mistralModel, and strategy, dropping allowedAddresses before it reached req.config.ocr. Pass it through in both the AppService loader (packages/data-schemas/src/app/ocr.ts) and the duplicate at packages/api/src/files/ocr.ts so the exemption survives config load. --- api/server/services/Files/Audio/STTService.js | 2 +- .../services/Files/Audio/STTService.spec.js | 37 +++++++++++- api/server/services/Files/Audio/TTSService.js | 2 +- .../services/Files/Audio/TTSService.spec.js | 34 ++++++++++- packages/api/src/files/ocr.ts | 1 + packages/data-provider/src/config.spec.ts | 56 +++++++++++++++++++ packages/data-provider/src/config.ts | 3 + packages/data-schemas/src/app/ocr.spec.ts | 27 +++++++++ packages/data-schemas/src/app/ocr.ts | 1 + 9 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 packages/data-schemas/src/app/ocr.spec.ts diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index 94512930c7..c5124ea7cf 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -157,7 +157,7 @@ class STTService { } const providers = Object.entries(sttSchema).filter( - ([, value]) => Object.keys(value).length > 0, + ([key, value]) => key !== 'allowedAddresses' && Object.keys(value).length > 0, ); if (providers.length !== 1) { diff --git a/api/server/services/Files/Audio/STTService.spec.js b/api/server/services/Files/Audio/STTService.spec.js index b91c1c95bd..6886e4c641 100644 --- a/api/server/services/Files/Audio/STTService.spec.js +++ b/api/server/services/Files/Audio/STTService.spec.js @@ -10,7 +10,7 @@ jest.mock('librechat-data-provider', () => ({ })); jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); -const { getFileExtensionFromMime, MIME_TO_EXTENSION_MAP } = require('./STTService'); +const { STTService, getFileExtensionFromMime, MIME_TO_EXTENSION_MAP } = require('./STTService'); describe('getFileExtensionFromMime', () => { it('should normalize audio/x-m4a to m4a', () => { @@ -51,6 +51,41 @@ describe('getFileExtensionFromMime', () => { }); }); +describe('STTService.getProviderSchema provider detection', () => { + const service = new STTService(); + + const buildReq = (stt) => ({ config: { speech: { stt } } }); + + it('resolves exactly one provider when allowedAddresses is set alongside it', async () => { + const req = buildReq({ + allowedAddresses: ['127.0.0.1:8080'], + openai: { url: 'http://127.0.0.1:8080', apiKey: 'sk', model: 'whisper-1' }, + }); + const [provider, schema] = await service.getProviderSchema(req); + expect(provider).toBe('openai'); + expect(schema.url).toBe('http://127.0.0.1:8080'); + }); + + it('reports "No provider is set" when only allowedAddresses is present', async () => { + const req = buildReq({ allowedAddresses: ['127.0.0.1:8080'] }); + await expect(service.getProviderSchema(req)).rejects.toThrow('No provider is set'); + }); + + it('reports "Multiple providers" when two providers are set even with allowedAddresses', async () => { + const req = buildReq({ + allowedAddresses: ['127.0.0.1:8080'], + openai: { url: 'http://127.0.0.1:8080', apiKey: 'sk', model: 'whisper-1' }, + azureOpenAI: { + instanceName: 'inst', + apiKey: 'sk', + deploymentName: 'dep', + apiVersion: '2024', + }, + }); + await expect(service.getProviderSchema(req)).rejects.toThrow('Multiple providers are set'); + }); +}); + describe('STT audio format validation with MIME normalization', () => { const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm']; diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index f8f47eda43..58ac157db5 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -52,7 +52,7 @@ class TTSService { ); } const providers = Object.entries(ttsSchema).filter( - ([, value]) => Object.keys(value).length > 0, + ([key, value]) => key !== 'allowedAddresses' && Object.keys(value).length > 0, ); if (providers.length !== 1) { diff --git a/api/server/services/Files/Audio/TTSService.spec.js b/api/server/services/Files/Audio/TTSService.spec.js index f3b506e3af..1f9fb79dc0 100644 --- a/api/server/services/Files/Audio/TTSService.spec.js +++ b/api/server/services/Files/Audio/TTSService.spec.js @@ -23,7 +23,7 @@ jest.mock('./streamAudio', () => ({ jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); const { resolveConfigSecret } = require('@librechat/api'); -const { TTSService } = require('./TTSService'); +const { TTSService, getProvider } = require('./TTSService'); describe('TTSService provider header construction with an undecryptable apiKey', () => { let service; @@ -70,3 +70,35 @@ describe('TTSService provider header construction with an undecryptable apiKey', expect(headers).not.toHaveProperty('Authorization'); }); }); + +describe('TTSService getProvider detection', () => { + const buildConfig = (tts) => ({ speech: { tts } }); + + it('resolves exactly one provider when allowedAddresses is set alongside it', async () => { + const provider = await getProvider( + buildConfig({ + allowedAddresses: ['localhost:11434'], + localai: { url: 'http://localhost:11434/tts', apiKey: 'sk' }, + }), + ); + expect(provider).toBe('localai'); + }); + + it('reports "No provider is set" when only allowedAddresses is present', async () => { + await expect( + getProvider(buildConfig({ allowedAddresses: ['localhost:11434'] })), + ).rejects.toThrow('No provider is set'); + }); + + it('reports "Multiple providers" when two providers are set even with allowedAddresses', async () => { + await expect( + getProvider( + buildConfig({ + allowedAddresses: ['localhost:11434'], + openai: { url: 'http://localhost:11434', apiKey: 'sk' }, + localai: { url: 'http://localhost:11434/tts', apiKey: 'sk' }, + }), + ), + ).rejects.toThrow('Multiple providers are set'); + }); +}); diff --git a/packages/api/src/files/ocr.ts b/packages/api/src/files/ocr.ts index 02060a858f..b60719d675 100644 --- a/packages/api/src/files/ocr.ts +++ b/packages/api/src/files/ocr.ts @@ -11,5 +11,6 @@ export function loadOCRConfig(config?: TCustomConfig['ocr']): TCustomConfig['ocr baseURL, mistralModel, strategy: config?.strategy ?? OCRStrategy.MISTRAL_OCR, + allowedAddresses: config?.allowedAddresses, }; } diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index 3b2ae383cb..0b5c3c573e 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -492,6 +492,62 @@ describe('allowedAddressesSchema', () => { }); expect(result.success).toBe(false); }); + + it('accepts the field on speech.stt', () => { + const result = configSchema.safeParse({ + version: '1.0', + speech: { stt: { allowedAddresses: ['127.0.0.1:8080'] } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts the field on speech.tts', () => { + const result = configSchema.safeParse({ + version: '1.0', + speech: { tts: { allowedAddresses: ['localhost:11434', 'ollama.internal:11434'] } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts the field on ocr', () => { + const result = configSchema.safeParse({ + version: '1.0', + ocr: { allowedAddresses: ['10.0.0.5:443'] }, + }); + expect(result.success).toBe(true); + }); + + it('omitting the field on ocr leaves it undefined', () => { + const result = configSchema.safeParse({ version: '1.0', ocr: {} }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ocr?.allowedAddresses).toBeUndefined(); + } + }); + + it('rejects a public IP at the speech.stt location', () => { + const result = configSchema.safeParse({ + version: '1.0', + speech: { stt: { allowedAddresses: ['8.8.8.8:53'] } }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a bare host at the speech.tts location', () => { + const result = configSchema.safeParse({ + version: '1.0', + speech: { tts: { allowedAddresses: ['localhost'] } }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a CIDR range at the ocr location', () => { + const result = configSchema.safeParse({ + version: '1.0', + ocr: { allowedAddresses: ['10.0.0.0/24'] }, + }); + expect(result.success).toBe(false); + }); }); }); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 38959f5cde..33328ab0f6 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1200,6 +1200,7 @@ const ttsLocalaiSchema = z.object({ }); const ttsSchema = z.object({ + allowedAddresses: allowedAddressesSchema, openai: ttsOpenaiSchema.optional(), azureOpenAI: ttsAzureOpenAISchema.optional(), elevenlabs: ttsElevenLabsSchema.optional(), @@ -1222,6 +1223,7 @@ const sttAzureOpenAISchema = z.object({ }); const sttSchema = z.object({ + allowedAddresses: allowedAddressesSchema, openai: sttOpenaiSchema.optional(), azureOpenAI: sttAzureOpenAISchema.optional(), }); @@ -1788,6 +1790,7 @@ export const webSearchSchema = z.object({ export type TWebSearchConfig = DeepPartial>; export const ocrSchema = z.object({ + allowedAddresses: allowedAddressesSchema, mistralModel: z.string().optional(), apiKey: z.string().optional().default('${OCR_API_KEY}'), apiKeyPreview: apiKeyPreviewSchema, diff --git a/packages/data-schemas/src/app/ocr.spec.ts b/packages/data-schemas/src/app/ocr.spec.ts new file mode 100644 index 0000000000..7c4cccaeb3 --- /dev/null +++ b/packages/data-schemas/src/app/ocr.spec.ts @@ -0,0 +1,27 @@ +import { OCRStrategy } from 'librechat-data-provider'; +import { loadOCRConfig } from './ocr'; + +describe('loadOCRConfig', () => { + it('returns undefined when no config is provided', () => { + expect(loadOCRConfig(undefined)).toBeUndefined(); + }); + + it('preserves allowedAddresses so the exemption survives config load', () => { + const loaded = loadOCRConfig({ + apiKey: '${OCR_API_KEY}', + baseURL: 'https://ocr.internal:8080', + strategy: OCRStrategy.MISTRAL_OCR, + allowedAddresses: ['ocr.internal:8080'], + }); + expect(loaded?.allowedAddresses).toEqual(['ocr.internal:8080']); + }); + + it('leaves allowedAddresses undefined when it is not configured', () => { + const loaded = loadOCRConfig({ + apiKey: 'key', + baseURL: 'https://api.mistral.ai', + strategy: OCRStrategy.MISTRAL_OCR, + }); + expect(loaded?.allowedAddresses).toBeUndefined(); + }); +}); diff --git a/packages/data-schemas/src/app/ocr.ts b/packages/data-schemas/src/app/ocr.ts index 02060a858f..b60719d675 100644 --- a/packages/data-schemas/src/app/ocr.ts +++ b/packages/data-schemas/src/app/ocr.ts @@ -11,5 +11,6 @@ export function loadOCRConfig(config?: TCustomConfig['ocr']): TCustomConfig['ocr baseURL, mistralModel, strategy: config?.strategy ?? OCRStrategy.MISTRAL_OCR, + allowedAddresses: config?.allowedAddresses, }; }