Reject oversized media before fully downloading it (#51896)

Signed-off-by: Clinton Thomas <1033162+KernelClint@users.noreply.github.com> Co-authored-by: Lucas Bourtoule <35483370+dhalf@users.noreply.github.com>

Clinton Thomas committed Aug 23, 2026 at 21:15 UTC 2ec6f0d71ea3b350952630e310efcda1c744ff4d
13 files changed +718 -24
docs/usage/security.md
+5 -3
@@ -85,13 +85,15 @@ significantly reduce the attack surface for these types of abuse.
85 Also, consider setting `VLLM_MEDIA_URL_ALLOW_REDIRECTS=0` to prevent HTTP
86 redirects from being followed to bypass domain restrictions.
87
88 -### 5. **Restrict Media Decode Sizes:**
88 +### 5. **Restrict Media Download and Decode Sizes:**
89
90 -Compressed media files can expand into gigabytes of memory during decoding. vLLM
91 -enforces decode-size limits to prevent out-of-memory denial of service:
90 +Remote media responses and compressed media files can expand into gigabytes of
91 +memory. vLLM enforces download and decode-size limits to prevent out-of-memory
92 +denial of service:
93
94 | Environment Variable | Default | Description |
95 | --- | --- | --- |
96 +| `VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB` | `256` | Maximum size in MB for a single remote media response. Oversized responses are rejected while streaming before the full body is materialized in memory. |
97 | `VLLM_MAX_IMAGE_PIXELS` | `178956970` (~179M pixels) | Maximum decoded image size in pixels. Images exceeding this are rejected before raster memory is allocated. Default matches PIL's built-in 2x decompression-bomb threshold (~680 MB for RGB). |
98 | `VLLM_MAX_AUDIO_CLIP_FILESIZE_MB` | `25` | Maximum filesize in MB for a single audio file. |
99 | `VLLM_MAX_AUDIO_DECODE_DURATION_S` | `600` | Maximum decoded audio duration in seconds. Prevents compressed audio from expanding into gigabytes of float32 PCM. |
tests/entrypoints/openai/test_run_batch.py
+160
@@ -4,16 +4,25 @@
4 import json
5 import subprocess
6 import tempfile
7 +import threading
8 +from collections.abc import Generator
9 +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10 from unittest.mock import AsyncMock, MagicMock, patch
11
12 import pytest
13
14 +import vllm.envs as envs
15 from vllm.assets.audio import AudioAsset
16 +from vllm.connections import HTTPConnection
17 +from vllm.entrypoints.openai.engine.protocol import ErrorResponse
18 from vllm.entrypoints.openai.run_batch import (
19 BatchRequestOutput,
20 + BatchTranscriptionRequest,
21 download_bytes_from_url,
22 + make_transcription_wrapper,
23 )
24 from vllm.exceptions import VLLMValidationError
25 +from vllm.utils.mem_constants import MiB_bytes
26
27 CHAT_MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM"
28 EMBEDDING_MODEL_NAME = "intfloat/multilingual-e5-small"
@@ -281,6 +290,74 @@ INPUT_REASONING_BATCH = "\n".join(
290 )
291
292 MINIMAL_WAV_BASE64 = "UklGRigAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQQAAAAAAP9/"
293 +_EXACT_LIMIT_AUDIO = b"a" * MiB_bytes
294 +_OVERSIZED_BASE64_AUDIO = "A" * (4 * ((MiB_bytes + 2) // 3))
295 +_OVERSIZED_HTTP_AUDIO = b"b" * (MiB_bytes + 1)
296 +
297 +
298 +class _BatchHTTPServer(ThreadingHTTPServer):
299 + request_paths: list[str]
300 +
301 +
302 +class _BatchHTTPHandler(BaseHTTPRequestHandler):
303 + def do_GET(self) -> None:
304 + server = self.server
305 + assert isinstance(server, _BatchHTTPServer)
306 + server.request_paths.append(self.path)
307 +
308 + if self.path == "/exact-limit":
309 + self._send_body(_EXACT_LIMIT_AUDIO)
310 + return
311 +
312 + if self.path == "/oversized-chunked":
313 + self.send_response(200)
314 + self.end_headers()
315 + self._write_body(_OVERSIZED_HTTP_AUDIO)
316 + return
317 +
318 + self.send_error(404, "Unknown batch HTTP test path")
319 +
320 + def _send_body(self, body: bytes) -> None:
321 + self.send_response(200)
322 + self.send_header("Content-Type", "application/octet-stream")
323 + self.send_header("Content-Length", str(len(body)))
324 + self.end_headers()
325 + self._write_body(body)
326 +
327 + def _write_body(self, body: bytes) -> None:
328 + try:
329 + for offset in range(0, len(body), 64 * 1024):
330 + self.wfile.write(body[offset : offset + 64 * 1024])
331 + except (BrokenPipeError, ConnectionResetError):
332 + self.close_connection = True
333 +
334 + def log_message(self, fmt: str, *args: object) -> None:
335 + return
336 +
337 +
338 +@pytest.fixture
339 +def batch_http_server() -> Generator[_BatchHTTPServer, None, None]:
340 + server = _BatchHTTPServer(("127.0.0.1", 0), _BatchHTTPHandler)
341 + server.request_paths = []
342 + thread = threading.Thread(target=server.serve_forever, daemon=True)
343 + thread.start()
344 + try:
345 + yield server
346 + finally:
347 + server.shutdown()
348 + thread.join()
349 + server.server_close()
350 +
351 +
352 +def _batch_http_url(server: _BatchHTTPServer, path: str) -> str:
353 + return f"http://localhost:{server.server_port}{path}"
354 +
355 +
356 +async def _close_async_connection(connection: HTTPConnection) -> None:
357 + if connection._async_client is not None:
358 + await connection._async_client.close()
359 +
360 +
361 INPUT_TRANSCRIPTION_BATCH = (
362 json.dumps(
363 {
@@ -761,8 +838,15 @@ def test_tool_calling():
838
839 def _make_aiohttp_mocks(response_data: bytes = b"fake-data", status: int = 200):
840 """Create mock objects that simulate aiohttp.ClientSession context managers."""
841 +
842 + async def iter_chunked(chunk_size: int):
843 + del chunk_size
844 + yield response_data
845 +
846 mock_resp = MagicMock()
847 mock_resp.status = status
848 + mock_resp.content_length = len(response_data)
849 + mock_resp.content.iter_chunked = iter_chunked
850 mock_resp.read = AsyncMock(return_value=response_data)
851 mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
852 mock_resp.__aexit__ = AsyncMock(return_value=False)
@@ -892,3 +976,79 @@ async def test_download_bytes_backslash_bypass():
976 await download_bytes_from_url(
977 bypass_url, allowed_media_domains=["evil.internal"]
978 )
979 +
980 +
981 +@pytest.mark.asyncio
982 +async def test_transcription_wrapper_rejects_oversized_data_url_before_decode(
983 + monkeypatch: pytest.MonkeyPatch,
984 +):
985 + monkeypatch.setattr(envs, "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", 1)
986 + handler = AsyncMock()
987 + wrapped_handler = make_transcription_wrapper(is_translation=False)(handler)
988 + request = BatchTranscriptionRequest.model_validate(
989 + {
990 + "model": SPEECH_LARGE_MODEL_NAME,
991 + "file_url": f"data:audio/wav;base64,{_OVERSIZED_BASE64_AUDIO}",
992 + "response_format": "json",
993 + }
994 + )
995 +
996 + with patch("vllm.entrypoints.openai.run_batch.base64.b64decode") as decode:
997 + response = await wrapped_handler(request)
998 +
999 + assert isinstance(response, ErrorResponse)
1000 + assert "Maximum file size exceeded" in response.error.message
1001 + decode.assert_not_called()
1002 + handler.assert_not_awaited()
1003 +
1004 +
1005 +@pytest.mark.asyncio
1006 +async def test_download_bytes_allows_http_body_at_audio_limit(
1007 + monkeypatch: pytest.MonkeyPatch,
1008 + batch_http_server: _BatchHTTPServer,
1009 +):
1010 + monkeypatch.setattr(envs, "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", 1)
1011 + connection = HTTPConnection()
1012 + monkeypatch.setattr(
1013 + "vllm.entrypoints.openai.run_batch.global_http_connection",
1014 + connection,
1015 + )
1016 +
1017 + try:
1018 + result = await download_bytes_from_url(
1019 + _batch_http_url(batch_http_server, "/exact-limit")
1020 + )
1021 + assert result == _EXACT_LIMIT_AUDIO
1022 + finally:
1023 + await _close_async_connection(connection)
1024 +
1025 +
1026 +@pytest.mark.asyncio
1027 +async def test_transcription_wrapper_rejects_oversized_http_before_handler(
1028 + monkeypatch: pytest.MonkeyPatch,
1029 + batch_http_server: _BatchHTTPServer,
1030 +):
1031 + monkeypatch.setattr(envs, "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", 1)
1032 + connection = HTTPConnection()
1033 + monkeypatch.setattr(
1034 + "vllm.entrypoints.openai.run_batch.global_http_connection",
1035 + connection,
1036 + )
1037 + handler = AsyncMock()
1038 + wrapped_handler = make_transcription_wrapper(is_translation=False)(handler)
1039 + request = BatchTranscriptionRequest.model_validate(
1040 + {
1041 + "model": SPEECH_LARGE_MODEL_NAME,
1042 + "file_url": _batch_http_url(batch_http_server, "/oversized-chunked"),
1043 + "response_format": "json",
1044 + }
1045 + )
1046 +
1047 + try:
1048 + response = await wrapped_handler(request)
1049 + finally:
1050 + await _close_async_connection(connection)
1051 +
1052 + assert isinstance(response, ErrorResponse)
1053 + assert "Maximum file size exceeded" in response.error.message
1054 + handler.assert_not_awaited()
tests/entrypoints/speech_to_text/transcription/test_chunk_timestamp_offset.py
-1
@@ -41,7 +41,6 @@ async def test_chunk_offsets_are_cumulative_not_nominal():
41 overlap_chunk_second=1,
42 min_energy_split_window_size=1600,
43 )
44 - serving.max_audio_filesize_mb = 100.0
44 serving.model_cls = MagicMock()
45 serving.model_cls.validate_language.side_effect = lambda lang: lang
46 serving.model_cls.supports_explicit_language_detection = False
tests/multimodal/media/test_unprocessable_entity_error.py
+14
@@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
13 import aiohttp
14 import pytest
15
16 +from vllm.connections import MediaDownloadSizeExceededError
17 from vllm.entrypoints.serve import create_error_response
18 from vllm.exceptions import VLLMClientError, VLLMUnprocessableEntityError
19 from vllm.multimodal.media import MediaConnector
@@ -134,6 +135,19 @@ class TestMediaConnectorErrorHandling:
135
136 assert isinstance(exc_info.value, aiohttp.ClientConnectionError)
137
138 + def test_fetch_image_download_size_limit_error(self):
139 + connector = MediaConnector()
140 +
141 + with patch.object(
142 + connector.connection, "get_bytes", new_callable=MagicMock
143 + ) as mock_get:
144 + mock_get.side_effect = MediaDownloadSizeExceededError(256 * 1024 * 1024)
145 +
146 + with pytest.raises(VLLMUnprocessableEntityError) as exc_info:
147 + connector.fetch_image("https://example.com/image.jpg")
148 +
149 + assert "VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB=256" in str(exc_info.value)
150 +
151
152 class TestErrorResponse:
153 """Tests for error response creation."""
tests/test_audio_media_size_precheck.py new
+129
@@ -0,0 +1,129 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from collections.abc import AsyncIterator, Iterator
5 +from unittest.mock import patch
6 +
7 +import pytest
8 +
9 +import vllm.envs as envs
10 +from vllm.connections import HTTPConnection
11 +from vllm.exceptions import VLLMValidationError
12 +from vllm.multimodal.media import AudioMediaIO, MediaConnector
13 +from vllm.utils.mem_constants import KiB_bytes, MiB_bytes
14 +
15 +pytestmark = [pytest.mark.cpu_test, pytest.mark.skip_global_cleanup]
16 +
17 +
18 +class _SyncResponse:
19 + def __init__(self, chunks: list[bytes], content_length: int | None = None):
20 + self.headers = (
21 + {} if content_length is None else {"content-length": str(content_length)}
22 + )
23 + self._chunks = chunks
24 + self.iterated = 0
25 +
26 + def __enter__(self):
27 + return self
28 +
29 + def __exit__(self, *_args):
30 + return None
31 +
32 + def raise_for_status(self) -> None:
33 + return None
34 +
35 + def iter_content(self, chunk_size: int) -> Iterator[bytes]:
36 + assert chunk_size == 64 * KiB_bytes
37 + for chunk in self._chunks:
38 + self.iterated += 1
39 + yield chunk
40 +
41 +
42 +class _AsyncContent:
43 + def __init__(self, chunks: list[bytes]):
44 + self._chunks = chunks
45 + self.iterated = 0
46 +
47 + async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]:
48 + assert chunk_size == 64 * KiB_bytes
49 + for chunk in self._chunks:
50 + self.iterated += 1
51 + yield chunk
52 +
53 +
54 +class _AsyncResponse:
55 + def __init__(self, chunks: list[bytes], content_length: int | None = None):
56 + self.content_length = content_length
57 + self.content = _AsyncContent(chunks)
58 +
59 + async def __aenter__(self):
60 + return self
61 +
62 + async def __aexit__(self, *_args):
63 + return None
64 +
65 + def raise_for_status(self) -> None:
66 + return None
67 +
68 +
69 +def test_audio_base64_rejects_before_decode(monkeypatch: pytest.MonkeyPatch):
70 + monkeypatch.setattr(envs, "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", 1)
71 + max_encoded_chars = 4 * ((MiB_bytes + 2) // 3)
72 +
73 + with (
74 + patch("vllm.multimodal.media.audio.pybase64.b64decode") as decode,
75 + pytest.raises(VLLMValidationError, match="Maximum file size exceeded"),
76 + ):
77 + AudioMediaIO().load_base64("audio/wav", "A" * (max_encoded_chars + 1))
78 +
79 + decode.assert_not_called()
80 +
81 +
82 +def test_sync_http_reader_rejects_from_content_length_before_body_read():
83 + response = _SyncResponse([b"A" * 10], content_length=MiB_bytes + 1)
84 + connection = HTTPConnection()
85 +
86 + with (
87 + patch.object(connection, "get_response", return_value=response) as get_response,
88 + pytest.raises(VLLMValidationError, match="Maximum file size exceeded"),
89 + ):
90 + connection.get_bytes("https://example.test/audio", max_bytes=MiB_bytes)
91 +
92 + assert get_response.call_args.kwargs["stream"] is True
93 + assert response.iterated == 0
94 +
95 +
96 +@pytest.mark.asyncio
97 +async def test_async_http_reader_stops_after_first_over_limit_chunk():
98 + response = _AsyncResponse([b"A" * (64 * KiB_bytes), b"B", b"C" * 10])
99 + connection = HTTPConnection()
100 +
101 + async def get_async_response(*_args, **_kwargs):
102 + return response
103 +
104 + with (
105 + patch.object(connection, "get_async_response", side_effect=get_async_response),
106 + pytest.raises(VLLMValidationError, match="Maximum file size exceeded"),
107 + ):
108 + await connection.async_get_bytes(
109 + "https://example.test/audio",
110 + max_bytes=64 * KiB_bytes,
111 + )
112 +
113 + assert response.content.iterated == 2
114 +
115 +
116 +@pytest.mark.asyncio
117 +async def test_audio_connector_threads_byte_limit_to_http_reader(
118 + monkeypatch: pytest.MonkeyPatch,
119 +):
120 + monkeypatch.setattr(envs, "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", 1)
121 +
122 + class _Connection:
123 + async def async_get_bytes(self, _url: str, **kwargs):
124 + assert kwargs["max_bytes"] == MiB_bytes
125 + raise VLLMValidationError("Maximum file size exceeded")
126 +
127 + connector = MediaConnector(connection=_Connection()) # type: ignore[arg-type]
128 + with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"):
129 + await connector.fetch_audio_async("https://example.test/audio")
tests/test_connections.py new
+192
@@ -0,0 +1,192 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import threading
5 +from collections.abc import Generator
6 +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7 +from unittest.mock import AsyncMock, patch
8 +
9 +import pytest
10 +
11 +import vllm.envs as envs
12 +from vllm.connections import HTTPConnection, HTTPResponseSizeExceededError
13 +
14 +_ONE_MIB = 1024 * 1024
15 +_SMALL_BODY = b"a" * (_ONE_MIB // 2)
16 +_LARGE_BODY = b"b" * (2 * _ONE_MIB)
17 +_CHUNK_SIZE = 64 * 1024
18 +
19 +
20 +class _MediaHandler(BaseHTTPRequestHandler):
21 + def do_GET(self) -> None:
22 + body = _LARGE_BODY if "large" in self.path else _SMALL_BODY
23 + self.send_response(200)
24 + self.send_header("Content-Type", "application/octet-stream")
25 + if self.path != "/chunked-large":
26 + self.send_header("Content-Length", str(len(body)))
27 + self.end_headers()
28 +
29 + try:
30 + for offset in range(0, len(body), _CHUNK_SIZE):
31 + self.wfile.write(body[offset : offset + _CHUNK_SIZE])
32 + except (BrokenPipeError, ConnectionResetError):
33 + self.close_connection = True
34 +
35 + def log_message(self, fmt: str, *args: object) -> None:
36 + return
37 +
38 +
39 +@pytest.fixture
40 +def local_media_server() -> Generator[str, None, None]:
41 + server = ThreadingHTTPServer(("127.0.0.1", 0), _MediaHandler)
42 + thread = threading.Thread(target=server.serve_forever, daemon=True)
43 + thread.start()
44 + try:
45 + yield f"http://127.0.0.1:{server.server_port}"
46 + finally:
47 + server.shutdown()
48 + thread.join()
49 + server.server_close()
50 +
51 +
52 +class _SyncResponse:
53 + def __init__(self, chunks: list[bytes]) -> None:
54 + self.headers: dict[str, str] = {}
55 + self._chunks = chunks
56 + self.content_accessed = False
57 + self.iterated = 0
58 +
59 + @property
60 + def content(self) -> bytes:
61 + self.content_accessed = True
62 + raise AssertionError("bounded reads must not access response.content")
63 +
64 + def iter_content(self, chunk_size: int):
65 + del chunk_size
66 + for chunk in self._chunks:
67 + self.iterated += 1
68 + yield chunk
69 +
70 + def raise_for_status(self) -> None:
71 + return
72 +
73 + def __enter__(self):
74 + return self
75 +
76 + def __exit__(self, exc_type, exc_value, traceback):
77 + return False
78 +
79 +
80 +class _AsyncContent:
81 + def __init__(self, chunks: list[bytes]) -> None:
82 + self._chunks = chunks
83 + self.iterated = 0
84 +
85 + async def iter_chunked(self, chunk_size: int):
86 + del chunk_size
87 + for chunk in self._chunks:
88 + self.iterated += 1
89 + yield chunk
90 +
91 +
92 +class _AsyncResponse:
93 + def __init__(self, chunks: list[bytes]) -> None:
94 + self.content = _AsyncContent(chunks)
95 + self.content_length = None
96 + self.read = AsyncMock(
97 + side_effect=AssertionError("bounded reads must not call response.read()")
98 + )
99 +
100 + def raise_for_status(self) -> None:
101 + return
102 +
103 + async def __aenter__(self):
104 + return self
105 +
106 + async def __aexit__(self, exc_type, exc_value, traceback):
107 + return False
108 +
109 +
110 +def test_get_bytes_with_limit_streams_instead_of_materializing() -> None:
111 + response = _SyncResponse([b"abcd", b"efgh", b"ijkl"])
112 + connection = HTTPConnection()
113 +
114 + with (
115 + patch.object(
116 + connection,
117 + "get_response",
118 + return_value=response,
119 + ) as get_response,
120 + pytest.raises(HTTPResponseSizeExceededError, match="maximum size"),
121 + ):
122 + connection.get_bytes("http://example.com/media", max_bytes=4)
123 +
124 + get_response.assert_called_once_with(
125 + "http://example.com/media",
126 + stream=True,
127 + timeout=None,
128 + allow_redirects=True,
129 + )
130 + assert response.content_accessed is False
131 + assert response.iterated == 2
132 +
133 +
134 +@pytest.mark.asyncio
135 +async def test_async_get_bytes_with_limit_streams_instead_of_materializing() -> None:
136 + response = _AsyncResponse([b"abcd", b"efgh", b"ijkl"])
137 + connection = HTTPConnection()
138 +
139 + with (
140 + patch.object(
141 + connection,
142 + "get_async_response",
143 + new=AsyncMock(return_value=response),
144 + ),
145 + pytest.raises(HTTPResponseSizeExceededError, match="maximum size"),
146 + ):
147 + await connection.async_get_bytes("http://example.com/media", max_bytes=4)
148 +
149 + response.read.assert_not_awaited()
150 + assert response.content.iterated == 2
151 +
152 +
153 +def test_get_bytes_rejects_content_length_over_limit(
154 + monkeypatch: pytest.MonkeyPatch, local_media_server: str
155 +) -> None:
156 + monkeypatch.setattr(envs, "VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB", 1, raising=False)
157 + connection = HTTPConnection(reuse_client=False)
158 +
159 + with pytest.raises(ValueError, match="VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB"):
160 + connection.get_bytes(f"{local_media_server}/large")
161 +
162 +
163 +@pytest.mark.asyncio
164 +async def test_async_get_bytes_rejects_stream_over_limit_without_content_length(
165 + monkeypatch: pytest.MonkeyPatch, local_media_server: str
166 +) -> None:
167 + monkeypatch.setattr(envs, "VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB", 1, raising=False)
168 + connection = HTTPConnection(reuse_client=False)
169 +
170 + try:
171 + with pytest.raises(ValueError, match="VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB"):
172 + await connection.async_get_bytes(f"{local_media_server}/chunked-large")
173 + finally:
174 + if connection._async_client is not None:
175 + await connection._async_client.close()
176 +
177 +
178 +@pytest.mark.asyncio
179 +async def test_get_bytes_allows_body_within_limit(
180 + monkeypatch: pytest.MonkeyPatch, local_media_server: str
181 +) -> None:
182 + monkeypatch.setattr(envs, "VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB", 1, raising=False)
183 + connection = HTTPConnection(reuse_client=False)
184 +
185 + assert connection.get_bytes(f"{local_media_server}/small") == _SMALL_BODY
186 +
187 + try:
188 + actual = await connection.async_get_bytes(f"{local_media_server}/small")
189 + assert actual == _SMALL_BODY
190 + finally:
191 + if connection._async_client is not None:
192 + await connection._async_client.close()
vllm/connections.py
+140 -5
@@ -13,7 +13,9 @@ import requests
13 from urllib3.util import parse_url
14
15 import vllm.envs as envs
16 +from vllm.exceptions import VLLMValidationError
17 from vllm.logger import init_logger
18 +from vllm.utils.mem_constants import KiB_bytes, MiB_bytes
19 from vllm.version import __version__ as VLLM_VERSION
20
21 logger = init_logger(__name__)
@@ -25,6 +27,128 @@ _T = TypeVar("_T")
27 # Attempt N uses: base_timeout * (_RETRY_BACKOFF_FACTOR ** N) for the
28 # per-attempt timeout and sleeps _RETRY_BACKOFF_FACTOR ** N seconds.
29 _RETRY_BACKOFF_FACTOR = 4
30 +_RESPONSE_READ_CHUNK_SIZE = 64 * KiB_bytes
31 +
32 +
33 +class HTTPResponseSizeExceededError(VLLMValidationError):
34 + """Raised when an HTTP response exceeds a caller-supplied byte ceiling."""
35 +
36 + def __init__(self, max_bytes: int, received_bytes: int) -> None:
37 + self.max_bytes = max_bytes
38 + self.received_bytes = received_bytes
39 + super().__init__(
40 + "Maximum file size exceeded: HTTP response exceeded maximum size "
41 + f"of {max_bytes} bytes (received at least {received_bytes} bytes)",
42 + parameter="audio_filesize_mb",
43 + value=received_bytes / MiB_bytes,
44 + )
45 +
46 +
47 +class MediaDownloadSizeExceededError(ValueError):
48 + """Raised when a remote media download exceeds the configured byte limit."""
49 +
50 + def __init__(self, max_bytes: int, received_bytes: int | None = None) -> None:
51 + self.max_bytes = max_bytes
52 + self.received_bytes = received_bytes
53 + max_size_mb = max_bytes / MiB_bytes
54 + message = (
55 + "Remote media download exceeds "
56 + f"VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB={max_size_mb:g}"
57 + )
58 + if received_bytes is not None:
59 + message += f" (received at least {received_bytes} bytes)"
60 + super().__init__(message)
61 +
62 +
63 +def _get_media_download_limit_bytes() -> int | None:
64 + max_size_mb = envs.VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB
65 + if max_size_mb <= 0:
66 + return None
67 + return max_size_mb * MiB_bytes
68 +
69 +
70 +def _get_response_size_limit(max_bytes: int | None) -> tuple[int | None, bool]:
71 + media_max_bytes = _get_media_download_limit_bytes()
72 + if media_max_bytes is not None and (
73 + max_bytes is None or media_max_bytes <= max_bytes
74 + ):
75 + return media_max_bytes, True
76 + return max_bytes, False
77 +
78 +
79 +def _raise_size_exceeded(
80 + max_bytes: int, received_bytes: int, media_download_limit: bool
81 +) -> None:
82 + if media_download_limit:
83 + raise MediaDownloadSizeExceededError(max_bytes, received_bytes)
84 + raise HTTPResponseSizeExceededError(max_bytes, received_bytes)
85 +
86 +
87 +def _check_content_length(
88 + content_length: str | int | None,
89 + max_bytes: int | None,
90 + media_download_limit: bool,
91 +) -> None:
92 + if content_length is None or max_bytes is None:
93 + return
94 + try:
95 + parsed_length = int(content_length)
96 + except (TypeError, ValueError):
97 + return
98 + if parsed_length > max_bytes:
99 + _raise_size_exceeded(max_bytes, parsed_length, media_download_limit)
100 +
101 +
102 +def _append_chunk(
103 + chunks: list[bytes],
104 + chunk: bytes,
105 + received_bytes: int,
106 + max_bytes: int | None,
107 + media_download_limit: bool,
108 +) -> int:
109 + if not chunk:
110 + return received_bytes
111 + received_bytes += len(chunk)
112 + if max_bytes is not None and received_bytes > max_bytes:
113 + _raise_size_exceeded(max_bytes, received_bytes, media_download_limit)
114 + chunks.append(chunk)
115 + return received_bytes
116 +
117 +
118 +def _read_response_bytes(response: requests.Response, max_bytes: int | None) -> bytes:
119 + max_bytes, media_download_limit = _get_response_size_limit(max_bytes)
120 + if max_bytes is None:
121 + return response.content
122 +
123 + _check_content_length(
124 + response.headers.get("Content-Length"),
125 + max_bytes,
126 + media_download_limit,
127 + )
128 + chunks: list[bytes] = []
129 + received_bytes = 0
130 + for chunk in response.iter_content(_RESPONSE_READ_CHUNK_SIZE):
131 + received_bytes = _append_chunk(
132 + chunks, chunk, received_bytes, max_bytes, media_download_limit
133 + )
134 + return b"".join(chunks)
135 +
136 +
137 +async def _async_read_response_bytes(
138 + response: aiohttp.ClientResponse, max_bytes: int | None
139 +) -> bytes:
140 + max_bytes, media_download_limit = _get_response_size_limit(max_bytes)
141 + if max_bytes is None:
142 + return await response.read()
143 +
144 + _check_content_length(response.content_length, max_bytes, media_download_limit)
145 + chunks: list[bytes] = []
146 + received_bytes = 0
147 + async for chunk in response.content.iter_chunked(_RESPONSE_READ_CHUNK_SIZE):
148 + received_bytes = _append_chunk(
149 + chunks, chunk, received_bytes, max_bytes, media_download_limit
150 + )
151 + return b"".join(chunks)
152
153
154 def _is_retryable(exc: Exception) -> bool:
@@ -277,14 +401,22 @@ class HTTPConnection:
401
402 @_sync_retry
403 def get_bytes(
280 - self, url: str, *, timeout: float | None = None, allow_redirects: bool = True
404 + self,
405 + url: str,
406 + *,
407 + timeout: float | None = None,
408 + allow_redirects: bool = True,
409 + max_bytes: int | None = None,
410 ) -> bytes:
411 with self.get_response(
283 - url, timeout=timeout, allow_redirects=allow_redirects
412 + url,
413 + stream=True,
414 + timeout=timeout,
415 + allow_redirects=allow_redirects,
416 ) as r:
417 r.raise_for_status()
418
287 - return r.content
419 + return _read_response_bytes(r, max_bytes)
420
421 @_async_retry
422 async def async_get_bytes(
@@ -293,13 +425,16 @@ class HTTPConnection:
425 *,
426 timeout: float | None = None,
427 allow_redirects: bool = True,
428 + max_bytes: int | None = None,
429 ) -> bytes:
430 async with await self.get_async_response(
298 - url, timeout=timeout, allow_redirects=allow_redirects
431 + url,
432 + timeout=timeout,
433 + allow_redirects=allow_redirects,
434 ) as r:
435 r.raise_for_status()
436
302 - return await r.read()
437 + return await _async_read_response_bytes(r, max_bytes)
438
439 def get_text(self, url: str, *, timeout: float | None = None) -> str:
440 with self.get_response(url, timeout=timeout) as r:
vllm/entrypoints/openai/run_batch.py
+26 -5
@@ -10,7 +10,7 @@ from argparse import Namespace
10 from collections.abc import Awaitable, Callable
11 from http import HTTPStatus
12 from io import BytesIO, StringIO
13 -from typing import Any, TypeAlias
13 +from typing import Any, NoReturn, TypeAlias
14 from urllib.parse import urlparse
15
16 import aiohttp
@@ -28,7 +28,7 @@ from urllib3.util import parse_url
28
29 import vllm.envs as envs
30 from vllm.config import config
31 -from vllm.connections import global_http_connection
31 +from vllm.connections import HTTPResponseSizeExceededError, global_http_connection
32 from vllm.engine.arg_utils import AsyncEngineArgs
33 from vllm.engine.protocol import EngineClient
34 from vllm.entrypoints.launchers.api_server.entry import init_app_state
@@ -68,11 +68,24 @@ from vllm.logger import init_logger
68 from vllm.reasoning import ReasoningParserManager
69 from vllm.utils import random_uuid
70 from vllm.utils.argparse_utils import FlexibleArgumentParser
71 +from vllm.utils.mem_constants import MiB_bytes
72 from vllm.version import __version__ as VLLM_VERSION
73
74 logger = init_logger(__name__)
75
76
77 +def _get_audio_download_limit_bytes() -> int:
78 + return int(envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB * MiB_bytes)
79 +
80 +
81 +def _raise_audio_filesize_limit(size_bytes: int) -> NoReturn:
82 + raise VLLMValidationError(
83 + "Maximum file size exceeded",
84 + parameter="audio_filesize_mb",
85 + value=size_bytes / MiB_bytes,
86 + )
87 +
88 +
89 class BatchTranscriptionRequest(TranscriptionRequest):
90 """
91 Batch transcription request that uses file_url instead of file.
@@ -474,6 +487,9 @@ async def download_bytes_from_url(
487 if "," in url:
488 header, data = url.split(",", 1)
489 if "base64" in header:
490 + decoded_size_bytes = len(data.rstrip("=")) * 3 // 4
491 + if decoded_size_bytes > _get_audio_download_limit_bytes():
492 + _raise_audio_filesize_limit(decoded_size_bytes)
493 return base64.b64decode(data)
494 else:
495 raise VLLMValidationError(
@@ -502,9 +518,14 @@ async def download_bytes_from_url(
518 # between urllib3 and aiohttp (e.g. backslash-@ attacks).
519 url = url_spec.url
520
505 - return await global_http_connection.async_get_bytes(
506 - url, allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS
507 - )
521 + try:
522 + return await global_http_connection.async_get_bytes(
523 + url,
524 + allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
525 + max_bytes=_get_audio_download_limit_bytes(),
526 + )
527 + except HTTPResponseSizeExceededError as exc:
528 + _raise_audio_filesize_limit(exc.received_bytes)
529
530 else:
531 raise VLLMValidationError(
vllm/entrypoints/speech_to_text/base/serving.py
-8
@@ -124,7 +124,6 @@ class SpeechToTextBaseServing(GenerateBaseServing):
124
125 self.enable_force_include_usage = enable_force_include_usage
126
127 - self.max_audio_filesize_mb = envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB
127 self.max_audio_decode_duration_s: int = envs.VLLM_MAX_AUDIO_DECODE_DURATION_S
128 self.max_audio_decode_bytes: int = envs.VLLM_MAX_AUDIO_DECODE_BYTES
129 if self.model_cls.supports_segment_timestamp:
@@ -274,13 +273,6 @@ class SpeechToTextBaseServing(GenerateBaseServing):
273 else None
274 )
275
277 - if len(audio_data) / 1024**2 > self.max_audio_filesize_mb:
278 - raise VLLMValidationError(
279 - "Maximum file size exceeded",
280 - parameter="audio_filesize_mb",
281 - value=len(audio_data) / 1024**2,
282 - )
283 -
276 # Run cpu intensive preprocess step in a separate thread pool executor.
277 chunks, duration = await self._decode_and_chunk_speech_async(audio_data)
278
vllm/envs.py
+7
@@ -75,6 +75,7 @@ if TYPE_CHECKING:
75 VLLM_MEDIA_CACHE_MAX_SIZE_MB: int = 5120
76 VLLM_MEDIA_CACHE_TTL_HOURS: float = 24
77 VLLM_MEDIA_FETCH_MAX_RETRIES: int = 3
78 + VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB: int = 256
79 VLLM_MEDIA_URL_ALLOW_REDIRECTS: bool = True
80 VLLM_MEDIA_LOADING_THREAD_COUNT: int = 8
81 VLLM_MAX_AUDIO_CLIP_FILESIZE_MB: int = 25
@@ -988,6 +989,12 @@ environment_variables: dict[str, Callable[[], Any]] = {
989 "VLLM_MEDIA_FETCH_MAX_RETRIES": lambda: int(
990 os.getenv("VLLM_MEDIA_FETCH_MAX_RETRIES", "3")
991 ),
992 + # Maximum size in MB for a single remote media download. The limit is
993 + # enforced while streaming the response body so oversized or infinite
994 + # responses cannot grow the API server heap without bound. Default is 256.
995 + "VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB": lambda: int(
996 + os.getenv("VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB", "256")
997 + ),
998 # Whether to allow HTTP redirects when fetching from media URLs.
999 # Default to True
1000 "VLLM_MEDIA_URL_ALLOW_REDIRECTS": lambda: bool(
vllm/multimodal/media/audio.py
+21
@@ -10,6 +10,7 @@ import pybase64
10 import torch
11
12 import vllm.envs as envs
13 +from vllm.exceptions import VLLMValidationError
14 from vllm.logger import init_logger
15 from vllm.multimodal.audio import resample_audio_pyav
16 from vllm.utils.import_utils import PlaceholderModule
@@ -275,7 +276,20 @@ class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]):
276 # for flexible control.
277 self.kwargs = kwargs
278
279 + def get_max_bytes(self) -> int:
280 + return int(envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB * MiB_bytes)
281 +
282 + def _validate_encoded_size(self, size: int) -> None:
283 + max_bytes = self.get_max_bytes()
284 + if size > max_bytes:
285 + raise VLLMValidationError(
286 + "Maximum file size exceeded",
287 + parameter="audio_filesize_mb",
288 + value=size / MiB_bytes,
289 + )
290 +
291 def load_bytes(self, data: bytes) -> tuple[npt.NDArray, float]:
292 + self._validate_encoded_size(len(data))
293 return load_audio(
294 BytesIO(data),
295 sr=None,
@@ -288,6 +302,13 @@ class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]):
302 media_type: str,
303 data: str,
304 ) -> tuple[npt.NDArray, float]:
305 + max_encoded_chars = 4 * ((self.get_max_bytes() + 2) // 3)
306 + if len(data) > max_encoded_chars:
307 + raise VLLMValidationError(
308 + "Maximum file size exceeded",
309 + parameter="audio_filesize_mb",
310 + value=(len(data) * 3 / 4) / MiB_bytes,
311 + )
312 return self.load_bytes(pybase64.b64decode(data))
313
314 def load_file(self, filepath: Path) -> tuple[npt.NDArray, float]:
vllm/multimodal/media/base.py
+4
@@ -79,6 +79,10 @@ class MediaIO(ABC, Generic[_T]):
79 merged.update(runtime_kwargs)
80 return merged
81
82 + def get_max_bytes(self) -> int | None:
83 + """Return the maximum encoded payload size accepted by this media IO."""
84 + return None
85 +
86 @abstractmethod
87 def load_bytes(self, data: bytes) -> _T:
88 raise NotImplementedError
vllm/multimodal/media/connector.py
+20 -2
@@ -22,8 +22,12 @@ from PIL import Image, UnidentifiedImageError
22 from urllib3.util import Url, parse_url
23
24 import vllm.envs as envs
25 -from vllm.connections import HTTPConnection, global_http_connection
26 -from vllm.exceptions import VLLMUnprocessableEntityError
25 +from vllm.connections import (
26 + HTTPConnection,
27 + MediaDownloadSizeExceededError,
28 + global_http_connection,
29 +)
30 +from vllm.exceptions import VLLMUnprocessableEntityError, VLLMValidationError
31 from vllm.logger import init_logger
32 from vllm.multimodal.video import get_video_loader_backend_for_processor
33 from vllm.utils.registry import ExtensionManager
@@ -70,6 +74,9 @@ def _wrap_media_fetch_error(
74 Original exception for transient errors (5xx, 408, 429, network blips)
75 or other exceptions
76 """
77 + if isinstance(exc, VLLMValidationError):
78 + return exc
79 +
80 if isinstance(exc, aiohttp.ClientResponseError):
81 if exc.status in (408, 429):
82 return exc
@@ -101,6 +108,13 @@ def _wrap_media_fetch_error(
108 value=url,
109 )
110
111 + if isinstance(exc, MediaDownloadSizeExceededError):
112 + return VLLMUnprocessableEntityError(
113 + f"Failed to fetch media from URL: {exc}",
114 + parameter="image_url",
115 + value=url,
116 + )
117 +
118 if isinstance(exc, ValueError):
119 return VLLMUnprocessableEntityError(
120 "Failed to fetch media from URL: Invalid URL",
@@ -363,6 +377,7 @@ class MediaConnector:
377
378 if url_spec.scheme and url_spec.scheme.startswith("http"):
379 self._assert_url_in_allowed_media_domains(url_spec)
380 + max_bytes = media_io.get_max_bytes()
381
382 cached = self._get_cached_bytes(url)
383 if cached is not None:
@@ -374,6 +389,7 @@ class MediaConnector:
389 url_spec.url,
390 timeout=fetch_timeout,
391 allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
392 + max_bytes=max_bytes,
393 )
394 except Exception as e:
395 wrapped = _wrap_media_fetch_error(url, e)
@@ -409,6 +425,7 @@ class MediaConnector:
425
426 if url_spec.scheme and url_spec.scheme.startswith("http"):
427 self._assert_url_in_allowed_media_domains(url_spec)
428 + max_bytes = media_io.get_max_bytes()
429
430 cached = await loop.run_in_executor(
431 global_thread_pool, self._get_cached_bytes, url
@@ -425,6 +442,7 @@ class MediaConnector:
442 url_spec.url,
443 timeout=fetch_timeout,
444 allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
445 + max_bytes=max_bytes,
446 )
447 except Exception as e:
448 wrapped = _wrap_media_fetch_error(url, e)