mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🔒 feat: Add allowedAddresses Exemption to Speech (STT/TTS) and OCR Config Schemas (#14559)
* 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.
This commit is contained in:
parent
3f0a1ec8d9
commit
4cec1a675f
9 changed files with 159 additions and 4 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ export function loadOCRConfig(config?: TCustomConfig['ocr']): TCustomConfig['ocr
|
|||
baseURL,
|
||||
mistralModel,
|
||||
strategy: config?.strategy ?? OCRStrategy.MISTRAL_OCR,
|
||||
allowedAddresses: config?.allowedAddresses,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<z.infer<typeof webSearchSchema>>;
|
||||
|
||||
export const ocrSchema = z.object({
|
||||
allowedAddresses: allowedAddressesSchema,
|
||||
mistralModel: z.string().optional(),
|
||||
apiKey: z.string().optional().default('${OCR_API_KEY}'),
|
||||
apiKeyPreview: apiKeyPreviewSchema,
|
||||
|
|
|
|||
27
packages/data-schemas/src/app/ocr.spec.ts
Normal file
27
packages/data-schemas/src/app/ocr.spec.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,5 +11,6 @@ export function loadOCRConfig(config?: TCustomConfig['ocr']): TCustomConfig['ocr
|
|||
baseURL,
|
||||
mistralModel,
|
||||
strategy: config?.strategy ?? OCRStrategy.MISTRAL_OCR,
|
||||
allowedAddresses: config?.allowedAddresses,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue