A high-throughput and memory-efficient inference and serving engine for LLMs
(security) Enforce audio upload size limit before full file materialization (#45510)
Signed-off-by: jperezde <jperezde@redhat.com>
Juan Pérez de Algaba committed
Jun 15, 2026 at 12:25 UTC
b997071ec493765abbed990c65843ed05e4708a8
4 files changed
+210
-2
tests/entrypoints/speech_to_text/test_upload_size_limit.py
new
+142
@@ -0,0 +1,142 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Regression tests for the speech-to-text upload size pre-check.
4
+
5
+These tests verify that over-limit audio uploads are rejected *before*
6
+the full file is materialized into memory, closing the vulnerability
7
+where vLLM would allocate memory proportional to an oversized upload
8
+before enforcing the VLLM_MAX_AUDIO_CLIP_FILESIZE_MB limit.
9
+"""
10
+
11
+from unittest.mock import AsyncMock, patch
12
+
13
+import pytest
14
+
15
+from vllm.entrypoints.speech_to_text.base.utils import read_upload_with_limit
16
+from vllm.exceptions import VLLMValidationError
17
+
18
+
19
+def _make_upload_file(data: bytes, *, size: int | None = None) -> AsyncMock:
20
+ """Create a mock UploadFile that yields data in chunks."""
21
+ mock = AsyncMock()
22
+ mock.size = size
23
+
24
+ offset = 0
25
+
26
+ async def _read(n: int = -1):
27
+ nonlocal offset
28
+ if n <= 0:
29
+ chunk = data[offset:]
30
+ offset = len(data)
31
+ return chunk
32
+ chunk = data[offset : offset + n]
33
+ offset += len(chunk)
34
+ return chunk
35
+
36
+ mock.read = AsyncMock(side_effect=_read)
37
+ return mock
38
+
39
+
40
+@pytest.mark.asyncio
41
+async def test_rejects_oversized_upload_via_content_length():
42
+ """File is rejected early when file.size exceeds the limit."""
43
+ max_mb = 1
44
+ oversized_bytes = max_mb * 1024 * 1024 + 1
45
+
46
+ upload = _make_upload_file(b"", size=oversized_bytes)
47
+
48
+ with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"):
49
+ await read_upload_with_limit(upload, max_size_mb=max_mb)
50
+
51
+ upload.read.assert_not_called()
52
+
53
+
54
+@pytest.mark.asyncio
55
+async def test_rejects_oversized_upload_via_chunked_read():
56
+ """File is rejected mid-read without materializing the full content."""
57
+ max_mb = 1
58
+ max_bytes = max_mb * 1024 * 1024
59
+ oversized_data = b"\x00" * (max_bytes + 1024)
60
+
61
+ upload = _make_upload_file(oversized_data, size=None)
62
+
63
+ with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"):
64
+ await read_upload_with_limit(upload, max_size_mb=max_mb)
65
+
66
+
67
+@pytest.mark.asyncio
68
+async def test_accepts_file_within_limit():
69
+ """File within the limit is read successfully."""
70
+ max_mb = 1
71
+ data = b"\x00" * (512 * 1024) # 512 KiB, well under 1 MB
72
+
73
+ upload = _make_upload_file(data, size=len(data))
74
+ result = await read_upload_with_limit(upload, max_size_mb=max_mb)
75
+
76
+ assert result == data
77
+
78
+
79
+@pytest.mark.asyncio
80
+async def test_accepts_file_at_exact_limit():
81
+ """File exactly at the limit boundary is accepted."""
82
+ max_mb = 1
83
+ max_bytes = max_mb * 1024 * 1024
84
+ data = b"\x00" * max_bytes
85
+
86
+ upload = _make_upload_file(data, size=len(data))
87
+ result = await read_upload_with_limit(upload, max_size_mb=max_mb)
88
+
89
+ assert result == data
90
+
91
+
92
+@pytest.mark.asyncio
93
+async def test_rejects_at_one_byte_over_limit():
94
+ """File one byte over the limit is rejected."""
95
+ max_mb = 1
96
+ max_bytes = max_mb * 1024 * 1024
97
+ data = b"\x00" * (max_bytes + 1)
98
+
99
+ upload = _make_upload_file(data, size=None)
100
+
101
+ with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"):
102
+ await read_upload_with_limit(upload, max_size_mb=max_mb)
103
+
104
+
105
+@pytest.mark.asyncio
106
+async def test_uses_env_default_when_no_limit_specified():
107
+ """Uses VLLM_MAX_AUDIO_CLIP_FILESIZE_MB when max_size_mb is not given."""
108
+ with patch("vllm.entrypoints.speech_to_text.base.utils.envs") as mock_envs:
109
+ mock_envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB = 2
110
+ max_bytes = 2 * 1024 * 1024
111
+ oversized_data = b"\x00" * (max_bytes + 1)
112
+
113
+ upload = _make_upload_file(oversized_data, size=None)
114
+
115
+ with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"):
116
+ await read_upload_with_limit(upload)
117
+
118
+
119
+@pytest.mark.asyncio
120
+async def test_chunked_read_does_not_fully_materialize():
121
+ """Verify that for large oversized files, we stop reading early.
122
+
123
+ The function reads in 64 KiB chunks and aborts once the accumulated
124
+ size exceeds the limit. We confirm that far fewer read calls were made
125
+ than would be required to fully materialize the file.
126
+ """
127
+ max_mb = 1
128
+ max_bytes = max_mb * 1024 * 1024
129
+ large_size = max_bytes * 10 # 10x the limit
130
+ data = b"\x00" * large_size
131
+
132
+ upload = _make_upload_file(data, size=None)
133
+
134
+ with pytest.raises(VLLMValidationError):
135
+ await read_upload_with_limit(upload, max_size_mb=max_mb)
136
+
137
+ chunk_size = 64 * 1024
138
+ calls_for_full_read = large_size // chunk_size + 1
139
+ calls_to_exceed_limit = max_bytes // chunk_size + 1
140
+ actual_calls = upload.read.call_count
141
+ assert actual_calls <= calls_to_exceed_limit + 1
142
+ assert actual_calls < calls_for_full_read
vllm/entrypoints/speech_to_text/base/utils.py
new
+64
@@ -0,0 +1,64 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Shared utilities for speech-to-text API routes."""
4
+
5
+from fastapi import UploadFile
6
+
7
+import vllm.envs as envs
8
+from vllm.exceptions import VLLMValidationError
9
+from vllm.utils.mem_constants import KiB_bytes, MiB_bytes
10
+
11
+_READ_CHUNK_SIZE = 64 * KiB_bytes
12
+
13
+
14
+async def read_upload_with_limit(
15
+ file: UploadFile,
16
+ max_size_mb: float | None = None,
17
+) -> bytes:
18
+ """Read an uploaded file enforcing a size limit *before* full
19
+ materialization.
20
+
21
+ The function first checks the Content-Length header (``file.size``) when
22
+ available. Regardless, it then performs a chunked read that stops as soon
23
+ as the accumulated bytes exceed the limit, ensuring that an oversized
24
+ upload never fully materializes in memory.
25
+
26
+ Args:
27
+ file: The FastAPI/Starlette ``UploadFile`` object.
28
+ max_size_mb: Maximum allowed compressed file size in megabytes.
29
+ Defaults to ``envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB``.
30
+
31
+ Returns:
32
+ The file content as ``bytes``.
33
+
34
+ Raises:
35
+ VLLMValidationError: If the file exceeds the configured size limit.
36
+ """
37
+ if max_size_mb is None:
38
+ max_size_mb = envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB
39
+
40
+ max_bytes = int(max_size_mb * MiB_bytes)
41
+
42
+ if file.size is not None and file.size > max_bytes:
43
+ raise VLLMValidationError(
44
+ "Maximum file size exceeded",
45
+ parameter="audio_filesize_mb",
46
+ value=file.size / MiB_bytes,
47
+ )
48
+
49
+ chunks: list[bytes] = []
50
+ total = 0
51
+ while True:
52
+ chunk = await file.read(_READ_CHUNK_SIZE)
53
+ if not chunk:
54
+ break
55
+ total += len(chunk)
56
+ if total > max_bytes:
57
+ raise VLLMValidationError(
58
+ "Maximum file size exceeded",
59
+ parameter="audio_filesize_mb",
60
+ value=total / MiB_bytes,
61
+ )
62
+ chunks.append(chunk)
63
+
64
+ return b"".join(chunks)
vllm/entrypoints/speech_to_text/transcription/api_router.py
+2
-1
@@ -13,6 +13,7 @@ from vllm.entrypoints.serve.utils.api_utils import (
13
load_aware_call,
14
with_cancellation,
15
)
16
+from vllm.entrypoints.speech_to_text.base.utils import read_upload_with_limit
17
from vllm.logger import init_logger
18
19
from .protocol import TranscriptionRequest, TranscriptionResponseVariant
@@ -45,7 +46,7 @@ async def create_transcriptions(
46
if handler is None:
47
raise NotImplementedError("The model does not support Transcriptions API")
48
48
- audio_data = await request.file.read()
49
+ audio_data = await read_upload_with_limit(request.file)
50
51
generator = await handler.create_transcription(audio_data, request, raw_request)
52
vllm/entrypoints/speech_to_text/translation/api_router.py
+2
-1
@@ -13,6 +13,7 @@ from vllm.entrypoints.serve.utils.api_utils import (
13
load_aware_call,
14
with_cancellation,
15
)
16
+from vllm.entrypoints.speech_to_text.base.utils import read_upload_with_limit
17
from vllm.logger import init_logger
18
19
from .protocol import TranslationRequest, TranslationResponseVariant
@@ -45,7 +46,7 @@ async def create_translations(
46
if handler is None:
47
raise NotImplementedError("The model does not support Translations API")
48
48
- audio_data = await request.file.read()
49
+ audio_data = await read_upload_with_limit(request.file)
50
51
generator = await handler.create_translation(audio_data, request, raw_request)
52