A high-throughput and memory-efficient inference and serving engine for LLMs
[Security] Fix DoS via audio decompression bomb in speech-to-text endpoint (#44970)
Signed-off-by: jperezde <jperezde@redhat.com>
Juan Pérez de Algaba committed
Jun 9, 2026 at 18:18 UTC
1b1359c33269446f13c05da9a90c25174cbea590
4 files changed
+90
-5
tests/multimodal/media/test_audio.py
+17
@@ -68,6 +68,23 @@ def test_audio_media_io_encode_base64(dummy_audio):
68
mock_write.assert_called_once()
69
70
71
+def test_load_audio_max_duration_respected(dummy_audio_bytes):
72
+ """Valid audio within the duration limit should load successfully."""
73
+ from io import BytesIO
74
+
75
+ y, sr = load_audio(BytesIO(dummy_audio_bytes), sr=None, max_duration_s=3600)
76
+ assert isinstance(y, np.ndarray)
77
+ assert len(y) > 0
78
+
79
+
80
+def test_load_audio_max_duration_rejected(dummy_audio_bytes):
81
+ """Audio exceeding the duration limit must be rejected during decode."""
82
+ from io import BytesIO
83
+
84
+ with pytest.raises(ValueError, match="exceeds maximum allowed duration"):
85
+ load_audio(BytesIO(dummy_audio_bytes), sr=None, max_duration_s=0.0001)
86
+
87
+
88
def test_audio_media_io_from_video(video_assets):
89
audio_io = AudioMediaIO()
90
video_path = video_assets[0].video_path
vllm/entrypoints/speech_to_text/base/serving.py
+6
-1
@@ -115,6 +115,7 @@ class OpenAISpeechToText(OpenAIServing):
115
self.enable_force_include_usage = enable_force_include_usage
116
117
self.max_audio_filesize_mb = envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB
118
+ self.max_audio_decode_duration_s: int = envs.VLLM_MAX_AUDIO_DECODE_DURATION_S
119
if self.model_cls.supports_segment_timestamp:
120
self.tokenizer = cast(
121
PreTrainedTokenizerBase,
@@ -216,7 +217,11 @@ class OpenAISpeechToText(OpenAIServing):
217
# pre-requisite for chunking, as it assumes Whisper SR.
218
try:
219
with io.BytesIO(audio_data) as buf:
219
- y, sr = load_audio(buf, sr=self.asr_config.sample_rate)
220
+ y, sr = load_audio(
221
+ buf,
222
+ sr=self.asr_config.sample_rate,
223
+ max_duration_s=self.max_audio_decode_duration_s,
224
+ )
225
except Exception as exc:
226
raise ValueError("Invalid or unsupported audio file.") from exc
227
vllm/envs.py
+9
@@ -78,6 +78,7 @@ if TYPE_CHECKING:
78
VLLM_MEDIA_URL_ALLOW_REDIRECTS: bool = True
79
VLLM_MEDIA_LOADING_THREAD_COUNT: int = 8
80
VLLM_MAX_AUDIO_CLIP_FILESIZE_MB: int = 25
81
+ VLLM_MAX_AUDIO_DECODE_DURATION_S: int = 600
82
VLLM_VIDEO_LOADER_BACKEND: str = "opencv"
83
VLLM_MEDIA_CONNECTOR: str = "http"
84
VLLM_MM_HASHER_ALGORITHM: str = "blake3"
@@ -955,6 +956,13 @@ environment_variables: dict[str, Callable[[], Any]] = {
956
"VLLM_MAX_AUDIO_CLIP_FILESIZE_MB": lambda: int(
957
os.getenv("VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", "25")
958
),
959
+ # Maximum decoded audio duration in seconds. Compressed audio files
960
+ # (e.g. OPUS at very low bitrate) can expand into gigabytes of float32
961
+ # PCM. This limit is enforced *during* decoding so the memory is never
962
+ # allocated. Default is 600s (10 minutes).
963
+ "VLLM_MAX_AUDIO_DECODE_DURATION_S": lambda: int(
964
+ os.getenv("VLLM_MAX_AUDIO_DECODE_DURATION_S", "600")
965
+ ),
966
# Backend for Video IO — selects the frame-sampling algorithm.
967
# - "opencv": uniform sampling.
968
# - "opencv_dynamic": duration-aware dynamic sampling.
@@ -2156,6 +2164,7 @@ def compile_factors() -> dict[str, object]:
2164
"VLLM_MEDIA_URL_ALLOW_REDIRECTS",
2165
"VLLM_MEDIA_LOADING_THREAD_COUNT",
2166
"VLLM_MAX_AUDIO_CLIP_FILESIZE_MB",
2167
+ "VLLM_MAX_AUDIO_DECODE_DURATION_S",
2168
"VLLM_VIDEO_LOADER_BACKEND",
2169
"VLLM_MEDIA_CONNECTOR",
2170
"VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME",
vllm/multimodal/media/audio.py
+58
-4
@@ -47,6 +47,7 @@ def load_audio_pyav(
47
*,
48
sr: float | None = 22050,
49
mono: bool = True,
50
+ max_duration_s: float | None = None,
51
) -> tuple[npt.NDArray, float]:
52
"""Load an audio file using PyAV (FFmpeg), returning float32 mono waveform.
53
@@ -57,6 +58,10 @@ def load_audio_pyav(
58
Args:
59
path: A :class:`~io.BytesIO` buffer, a filesystem
60
:class:`~pathlib.Path`, or a string path.
61
+ max_duration_s: If set, abort decoding once the accumulated
62
+ sample count exceeds this many seconds of audio. Prevents
63
+ decompression-bomb attacks where a small compressed file
64
+ expands into gigabytes of PCM.
65
66
Returns:
67
``(waveform, sample_rate)`` where *waveform* is a 1-D float32
@@ -72,6 +77,30 @@ def load_audio_pyav(
77
native_sr = stream.rate
78
sr = sr or native_sr
79
80
+ # Early rejection from container/stream metadata to avoid
81
+ # wasting resources on decoding decompression bombs.
82
+ if max_duration_s is not None:
83
+ metadata_duration_s = None
84
+ if stream.duration and stream.time_base:
85
+ metadata_duration_s = float(stream.duration * stream.time_base)
86
+ elif container.duration:
87
+ metadata_duration_s = container.duration / 1_000_000
88
+ if (
89
+ metadata_duration_s is not None
90
+ and metadata_duration_s > max_duration_s
91
+ ):
92
+ raise ValueError(
93
+ f"Audio exceeds maximum allowed duration of "
94
+ f"{max_duration_s}s (metadata reports "
95
+ f"{metadata_duration_s:.1f}s). This limit "
96
+ f"prevents decompression-bomb attacks."
97
+ )
98
+
99
+ max_samples = (
100
+ int(sr * max_duration_s) if max_duration_s is not None else None
101
+ )
102
+ total_samples = 0
103
+
104
chunks: list[npt.NDArray] = []
105
needs_resampling = not math.isclose(
106
float(sr),
@@ -88,9 +117,21 @@ def load_audio_pyav(
117
if needs_resampling:
118
assert resampler is not None
119
for out_frame in resampler.resample(frame):
91
- chunks.append(out_frame.to_ndarray())
120
+ arr = out_frame.to_ndarray()
121
+ total_samples += arr.shape[-1]
122
+ chunks.append(arr)
123
else:
93
- chunks.append(frame.to_ndarray())
124
+ arr = frame.to_ndarray()
125
+ total_samples += arr.shape[-1]
126
+ chunks.append(arr)
127
+
128
+ if max_samples is not None and total_samples > max_samples:
129
+ raise ValueError(
130
+ f"Audio exceeds maximum allowed duration of "
131
+ f"{max_duration_s}s (decoded {total_samples} "
132
+ f"samples at {sr}Hz). This limit prevents "
133
+ f"decompression-bomb attacks."
134
+ )
135
except (ValueError, ImportError):
136
raise
137
except Exception as e:
@@ -114,10 +155,20 @@ def load_audio_soundfile(
155
*,
156
sr: float | None = 22050,
157
mono: bool = True,
158
+ max_duration_s: float | None = None,
159
) -> tuple[np.ndarray, int]:
160
"""Load audio via soundfile"""
161
with soundfile.SoundFile(path) as f:
162
native_sr = f.samplerate
163
+ if max_duration_s is not None:
164
+ file_duration_s = f.frames / native_sr
165
+ if file_duration_s > max_duration_s:
166
+ raise ValueError(
167
+ f"Audio exceeds maximum allowed duration of "
168
+ f"{max_duration_s}s (file contains "
169
+ f"{file_duration_s:.1f}s at {native_sr}Hz). "
170
+ f"This limit prevents decompression-bomb attacks."
171
+ )
172
y = f.read(dtype="float32", always_2d=False).T
173
174
if mono and y.ndim > 1:
@@ -134,9 +185,12 @@ def load_audio(
185
*,
186
sr: float | None = 22050,
187
mono: bool = True,
188
+ max_duration_s: float | None = None,
189
):
190
try:
139
- return load_audio_soundfile(path, sr=sr, mono=mono)
191
+ return load_audio_soundfile(
192
+ path, sr=sr, mono=mono, max_duration_s=max_duration_s
193
+ )
194
except ImportError as exc:
195
# soundfile (or resampy) is not installed — fall through to pyav.
196
# NOTE: this clause must stay BEFORE ``soundfile.LibsndfileError``
@@ -153,7 +207,7 @@ def load_audio(
207
if isinstance(path, BytesIO):
208
path.seek(0)
209
try:
156
- return load_audio_pyav(path, sr=sr, mono=mono)
210
+ return load_audio_pyav(path, sr=sr, mono=mono, max_duration_s=max_duration_s)
211
except ImportError:
212
raise # Let PlaceholderModule's message ("install vllm[audio]") propagate.
213
except Exception as pyav_exc: