@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 1b1359c332
Fork 0

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: