From f15b9d5a47291a115ca459691b0b77ed82954692 Mon Sep 17 00:00:00 2001 From: Tobias Jonas Date: Sun, 12 Apr 2026 23:37:38 +0200 Subject: [PATCH 1/2] fix: normalize audio MIME types in STT format validation Use getFileExtensionFromMime() to normalize non-standard MIME types (e.g. audio/x-m4a, audio/x-wav, audio/x-flac) before checking against the accepted formats list in azureOpenAIProvider. This is the same class of bug as #12608 (text/x-markdown), but for STT audio validation. Only audio/ and video/ MIME prefixes are normalized to prevent non-audio types from matching via the webm default fallback. Export getFileExtensionFromMime for testability. Fixes #12632 --- api/server/services/Files/Audio/STTService.js | 11 ++- .../services/Files/Audio/STTService.spec.js | 97 +++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 api/server/services/Files/Audio/STTService.spec.js diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index c9a35c35ea0..fc59bedb845 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -238,9 +238,12 @@ class STTService { } const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm']; - const fileFormat = audioFile.mimetype.split('/')[1]; - if (!acceptedFormats.includes(fileFormat)) { - throw new Error(`The audio file format ${fileFormat} is not accepted`); + const mimePrefix = audioFile.mimetype.split('/')[0]; + const rawFormat = audioFile.mimetype.split('/')[1]; + const isAudioMime = mimePrefix === 'audio' || mimePrefix === 'video'; + const normalizedFormat = isAudioMime ? getFileExtensionFromMime(audioFile.mimetype) : null; + if (!acceptedFormats.includes(normalizedFormat) && !acceptedFormats.includes(rawFormat)) { + throw new Error(`The audio file format ${rawFormat} is not accepted`); } const formData = new FormData(); @@ -377,4 +380,4 @@ async function speechToText(req, res) { await sttService.processSpeechToText(req, res); } -module.exports = { STTService, speechToText }; +module.exports = { STTService, speechToText, getFileExtensionFromMime }; diff --git a/api/server/services/Files/Audio/STTService.spec.js b/api/server/services/Files/Audio/STTService.spec.js new file mode 100644 index 00000000000..fba3b07ae02 --- /dev/null +++ b/api/server/services/Files/Audio/STTService.spec.js @@ -0,0 +1,97 @@ +// Mock all external dependencies so we can test getFileExtensionFromMime in isolation +jest.mock('axios'); +jest.mock('form-data'); +jest.mock('https-proxy-agent'); +jest.mock('@librechat/data-schemas', () => ({ logger: { warn: jest.fn(), error: jest.fn() } })); +jest.mock('@librechat/api', () => ({ genAzureEndpoint: jest.fn(), logAxiosError: jest.fn() })); +jest.mock('librechat-data-provider', () => ({ + extractEnvVariable: jest.fn(), + STTProviders: {}, +})); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + +const { getFileExtensionFromMime } = require('./STTService'); + +describe('getFileExtensionFromMime', () => { + it('should normalize audio/x-m4a to m4a', () => { + expect(getFileExtensionFromMime('audio/x-m4a')).toBe('m4a'); + }); + + it('should normalize audio/mp4 to m4a', () => { + expect(getFileExtensionFromMime('audio/mp4')).toBe('m4a'); + }); + + it('should normalize audio/x-wav to wav', () => { + expect(getFileExtensionFromMime('audio/x-wav')).toBe('wav'); + }); + + it('should normalize audio/x-flac to flac', () => { + expect(getFileExtensionFromMime('audio/x-flac')).toBe('flac'); + }); + + it('should normalize audio/mpeg to mp3', () => { + expect(getFileExtensionFromMime('audio/mpeg')).toBe('mp3'); + }); + + it('should return webm for audio/webm', () => { + expect(getFileExtensionFromMime('audio/webm')).toBe('webm'); + }); + + it('should return ogg for audio/ogg', () => { + expect(getFileExtensionFromMime('audio/ogg')).toBe('ogg'); + }); + + it('should fall back to webm for unknown MIME types', () => { + expect(getFileExtensionFromMime('audio/somethingelse')).toBe('webm'); + }); + + it('should return webm for null/undefined input', () => { + expect(getFileExtensionFromMime(null)).toBe('webm'); + expect(getFileExtensionFromMime(undefined)).toBe('webm'); + }); +}); + +describe('STT audio format validation with MIME normalization', () => { + const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm']; + + /** + * Simulates the format validation logic in azureOpenAIProvider after the fix. + * Only normalizes audio/video MIME types to prevent non-audio types from + * matching via the webm default fallback in getFileExtensionFromMime(). + */ + function isFormatAccepted(mimetype) { + const mimePrefix = mimetype.split('/')[0]; + const rawFormat = mimetype.split('/')[1]; + const isAudioMime = mimePrefix === 'audio' || mimePrefix === 'video'; + const normalizedFormat = isAudioMime ? getFileExtensionFromMime(mimetype) : null; + return acceptedFormats.includes(normalizedFormat) || acceptedFormats.includes(rawFormat); + } + + it('should accept audio/x-m4a (browser MIME for .m4a files)', () => { + expect(isFormatAccepted('audio/x-m4a')).toBe(true); + }); + + it('should accept audio/x-wav', () => { + expect(isFormatAccepted('audio/x-wav')).toBe(true); + }); + + it('should accept audio/x-flac', () => { + expect(isFormatAccepted('audio/x-flac')).toBe(true); + }); + + it('should accept standard formats directly', () => { + expect(isFormatAccepted('audio/mpeg')).toBe(true); + expect(isFormatAccepted('audio/wav')).toBe(true); + expect(isFormatAccepted('audio/ogg')).toBe(true); + expect(isFormatAccepted('audio/webm')).toBe(true); + expect(isFormatAccepted('audio/flac')).toBe(true); + expect(isFormatAccepted('audio/mp3')).toBe(true); + expect(isFormatAccepted('audio/mp4')).toBe(true); + expect(isFormatAccepted('audio/mpga')).toBe(true); + }); + + it('should reject unsupported formats', () => { + expect(isFormatAccepted('text/plain')).toBe(false); + expect(isFormatAccepted('application/json')).toBe(false); + }); +}); From 6ff62e2ef7ebb3317b1c98fe4d9e9fbc13c8a403 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 15 Apr 2026 09:23:54 -0400 Subject: [PATCH 2/2] fix: reject unknown audio subtypes in STT format validation Use MIME_TO_EXTENSION_MAP for normalization instead of getFileExtensionFromMime() which falls back to 'webm' for unrecognized types. Gate raw subtype matching on audio/video prefix to prevent non-audio types (e.g. text/webm) from passing validation. Resolves Codex review comment about unknown subtypes silently passing. --- api/server/services/Files/Audio/STTService.js | 13 ++++--- .../services/Files/Audio/STTService.spec.js | 34 ++++++++++++++----- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index fc59bedb845..7329bf6ac22 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -238,11 +238,14 @@ class STTService { } const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm']; - const mimePrefix = audioFile.mimetype.split('/')[0]; - const rawFormat = audioFile.mimetype.split('/')[1]; + const [mimePrefix, rawFormat = ''] = audioFile.mimetype.split('/'); const isAudioMime = mimePrefix === 'audio' || mimePrefix === 'video'; - const normalizedFormat = isAudioMime ? getFileExtensionFromMime(audioFile.mimetype) : null; - if (!acceptedFormats.includes(normalizedFormat) && !acceptedFormats.includes(rawFormat)) { + const isKnownMime = audioFile.mimetype in MIME_TO_EXTENSION_MAP; + const normalizedFormat = isKnownMime ? MIME_TO_EXTENSION_MAP[audioFile.mimetype] : null; + if ( + !acceptedFormats.includes(normalizedFormat) && + !(isAudioMime && acceptedFormats.includes(rawFormat)) + ) { throw new Error(`The audio file format ${rawFormat} is not accepted`); } @@ -380,4 +383,4 @@ async function speechToText(req, res) { await sttService.processSpeechToText(req, res); } -module.exports = { STTService, speechToText, getFileExtensionFromMime }; +module.exports = { STTService, speechToText, getFileExtensionFromMime, MIME_TO_EXTENSION_MAP }; diff --git a/api/server/services/Files/Audio/STTService.spec.js b/api/server/services/Files/Audio/STTService.spec.js index fba3b07ae02..b91c1c95bd3 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 } = require('./STTService'); +const { getFileExtensionFromMime, MIME_TO_EXTENSION_MAP } = require('./STTService'); describe('getFileExtensionFromMime', () => { it('should normalize audio/x-m4a to m4a', () => { @@ -55,16 +55,21 @@ describe('STT audio format validation with MIME normalization', () => { const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm']; /** - * Simulates the format validation logic in azureOpenAIProvider after the fix. - * Only normalizes audio/video MIME types to prevent non-audio types from - * matching via the webm default fallback in getFileExtensionFromMime(). + * Mirrors the format validation logic in azureOpenAIProvider. + * Only uses MIME_TO_EXTENSION_MAP for normalization so unknown audio + * subtypes are not silently accepted via the webm default fallback. + * Raw subtype matching is gated on audio/video prefix to prevent + * non-audio types like text/webm from passing. */ function isFormatAccepted(mimetype) { - const mimePrefix = mimetype.split('/')[0]; - const rawFormat = mimetype.split('/')[1]; + const [mimePrefix, rawFormat = ''] = mimetype.split('/'); const isAudioMime = mimePrefix === 'audio' || mimePrefix === 'video'; - const normalizedFormat = isAudioMime ? getFileExtensionFromMime(mimetype) : null; - return acceptedFormats.includes(normalizedFormat) || acceptedFormats.includes(rawFormat); + const isKnownMime = mimetype in MIME_TO_EXTENSION_MAP; + const normalizedFormat = isKnownMime ? MIME_TO_EXTENSION_MAP[mimetype] : null; + return ( + acceptedFormats.includes(normalizedFormat) || + (isAudioMime && acceptedFormats.includes(rawFormat)) + ); } it('should accept audio/x-m4a (browser MIME for .m4a files)', () => { @@ -90,7 +95,18 @@ describe('STT audio format validation with MIME normalization', () => { expect(isFormatAccepted('audio/mpga')).toBe(true); }); - it('should reject unsupported formats', () => { + it('should reject unknown audio subtypes', () => { + expect(isFormatAccepted('audio/aac')).toBe(false); + expect(isFormatAccepted('audio/somethingelse')).toBe(false); + expect(isFormatAccepted('video/unknown')).toBe(false); + }); + + it('should accept application/ogg (valid Ogg container MIME type in the map)', () => { + expect(isFormatAccepted('application/ogg')).toBe(true); + }); + + it('should reject non-audio types even if subtype matches an accepted format', () => { + expect(isFormatAccepted('text/webm')).toBe(false); expect(isFormatAccepted('text/plain')).toBe(false); expect(isFormatAccepted('application/json')).toBe(false); });