fix(security): prevent image decompression bomb OOM denial of service (#47010)

Signed-off-by: jperezde <jperezde@redhat.com>

Juan Pérez de Algaba committed Jun 30, 2026 at 11:39 UTC 364ee36af1ed72a7ae3700b14db8099bc1b94e6c
7 files changed +115 -12
docs/usage/security.md
+15
@@ -85,6 +85,21 @@ 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:**
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:
92 +
93 +| Environment Variable | Default | Description |
94 +| --- | --- | --- |
95 +| `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). |
96 +| `VLLM_MAX_AUDIO_CLIP_FILESIZE_MB` | `25` | Maximum filesize in MB for a single audio file. |
97 +| `VLLM_MAX_AUDIO_DECODE_DURATION_S` | `600` | Maximum decoded audio duration in seconds. Prevents compressed audio from expanding into gigabytes of float32 PCM. |
98 +
99 +Setting any of these to `0` disables the corresponding limit. This is **not
100 +recommended** for deployments exposed to untrusted users, as it removes the
101 +protection against resource-exhaustion attacks.
102 +
103 ## Security and Firewalls: Protecting Exposed vLLM Systems
104
105 While vLLM is designed to allow unsafe network services to be isolated to
tests/multimodal/media/test_image.py
+54
@@ -205,3 +205,57 @@ def test_image_media_io_load_file(tmp_path):
205
206 with pytest.raises(ValueError, match="Failed to load image"):
207 image_io.load_file(truncated_real_file)
208 +
209 +
210 +def test_image_pixel_limit_respected():
211 + """A small image within the pixel limit loads successfully."""
212 + import vllm.envs as envs
213 +
214 + image = Image.new("RGB", (100, 100), (255, 0, 0))
215 + from io import BytesIO
216 +
217 + buf = BytesIO()
218 + image.save(buf, format="PNG")
219 + data = buf.getvalue()
220 +
221 + assert envs.VLLM_MAX_IMAGE_PIXELS >= 100 * 100
222 +
223 + image_io = ImageMediaIO()
224 + result = image_io.load_bytes(data)
225 + assert result.media.size == (100, 100)
226 +
227 +
228 +def test_image_pixel_limit_rejected(monkeypatch):
229 + """An image exceeding the pixel limit is rejected before raster decode."""
230 + import vllm.envs as envs
231 +
232 + monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100)
233 +
234 + image = Image.new("RGB", (20, 20), (0, 255, 0))
235 + from io import BytesIO
236 +
237 + buf = BytesIO()
238 + image.save(buf, format="PNG")
239 + data = buf.getvalue()
240 +
241 + image_io = ImageMediaIO()
242 + with pytest.raises(ValueError, match="exceed"):
243 + image_io.load_bytes(data)
244 +
245 +
246 +def test_image_pixel_limit_disabled(monkeypatch):
247 + """Setting VLLM_MAX_IMAGE_PIXELS=0 disables the pixel limit."""
248 + import vllm.envs as envs
249 +
250 + monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 0)
251 +
252 + image = Image.new("RGB", (1000, 1000), (0, 0, 255))
253 + from io import BytesIO
254 +
255 + buf = BytesIO()
256 + image.save(buf, format="PNG")
257 + data = buf.getvalue()
258 +
259 + image_io = ImageMediaIO()
260 + result = image_io.load_bytes(data)
261 + assert result.media.size == (1000, 1000)
vllm/envs.py
+9
@@ -79,6 +79,7 @@ if TYPE_CHECKING:
79 VLLM_MAX_AUDIO_CLIP_FILESIZE_MB: int = 25
80 VLLM_MAX_AUDIO_DECODE_DURATION_S: int = 600
81 VLLM_MAX_AUDIO_PREPROCESS_WORKERS: int = max(1, min(os.cpu_count() or 1, 2))
82 + VLLM_MAX_IMAGE_PIXELS: int = 178_956_970
83 VLLM_VIDEO_LOADER_BACKEND: str = "opencv"
84 VLLM_MEDIA_CONNECTOR: str = "http"
85 VLLM_MM_HASHER_ALGORITHM: str = "blake3"
@@ -954,6 +955,13 @@ environment_variables: dict[str, Callable[[], Any]] = {
955 str(max(1, min(os.cpu_count() or 1, 2))),
956 )
957 ),
958 + # Maximum decoded image size in pixels. Small compressed images can
959 + # expand into gigabytes of raster memory. This limit is enforced before
960 + # decoding so the memory is never allocated. Default matches PIL's
961 + # built-in 2x decompression-bomb threshold (~179M pixels, ~680 MB RGB).
962 + "VLLM_MAX_IMAGE_PIXELS": lambda: int(
963 + os.getenv("VLLM_MAX_IMAGE_PIXELS", "178956970")
964 + ),
965 # Backend for Video IO — selects the frame-sampling algorithm.
966 # - "opencv": uniform sampling.
967 # - "opencv_dynamic": duration-aware dynamic sampling.
@@ -2083,6 +2091,7 @@ def compile_factors() -> dict[str, object]:
2091 "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB",
2092 "VLLM_MAX_AUDIO_DECODE_DURATION_S",
2093 "VLLM_MAX_AUDIO_PREPROCESS_WORKERS",
2094 + "VLLM_MAX_IMAGE_PIXELS",
2095 "VLLM_VIDEO_LOADER_BACKEND",
2096 "VLLM_MEDIA_CONNECTOR",
2097 "VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME",
vllm/multimodal/media/image.py
+9
@@ -9,6 +9,7 @@ import pybase64
9 import torch
10 from PIL import Image
11
12 +import vllm.envs as envs
13 from vllm.utils.serial_utils import tensor2base64
14
15 from ..image import convert_image_mode, normalize_image, rgba_to_rgb
@@ -72,6 +73,14 @@ class ImageMediaIO(MediaIO[Image.Image]):
73 def load_bytes(self, data: bytes) -> MediaWithBytes[Image.Image]:
74 try:
75 image = Image.open(BytesIO(data))
76 + w, h = image.size
77 + max_pixels = envs.VLLM_MAX_IMAGE_PIXELS
78 + if max_pixels > 0 and w * h > max_pixels:
79 + raise ValueError(
80 + f"Image dimensions {w}x{h} ({w * h} pixels) exceed "
81 + f"the maximum of {max_pixels} pixels. Set "
82 + f"VLLM_MAX_IMAGE_PIXELS to increase this limit."
83 + )
84 image = normalize_image(image)
85 image.load()
86 image = self._convert_image_mode(image)
vllm/multimodal/video.py
+28
@@ -13,6 +13,7 @@ import numpy as np
13 import numpy.typing as npt
14 import torch
15
16 +from vllm import envs
17 from vllm.logger import init_logger
18 from vllm.utils.import_utils import PlaceholderModule
19 from vllm.utils.mem_constants import MiB_bytes
@@ -88,6 +89,18 @@ def get_video_loader_backend_for_processor(
89 return VIDEO_LOADER_REGISTRY.get_backend_for_video_processor(video_processor)
90
91
92 +def _check_frame_pixel_limit(width: int, height: int) -> None:
93 + """Reject video frames exceeding VLLM_MAX_IMAGE_PIXELS before decoding."""
94 + max_pixels = envs.VLLM_MAX_IMAGE_PIXELS
95 + if max_pixels > 0 and width * height > max_pixels:
96 + raise ValueError(
97 + f"Video frame dimensions {width}x{height} "
98 + f"({width * height} pixels) exceed the maximum of "
99 + f"{max_pixels} pixels. Set VLLM_MAX_IMAGE_PIXELS to "
100 + f"increase this limit."
101 + )
102 +
103 +
104 def resize_video(frames: npt.NDArray, size: tuple[int, int]) -> npt.NDArray:
105 num_frames, _, _, channels = frames.shape
106 new_height, new_width = size
@@ -733,6 +746,7 @@ class PyNvVideoCodecVideoBackendMixin:
746 temp_file.write(data)
747
748 gpu_source = cls._read_source_metadata(temp_path, nvc)
749 + _check_frame_pixel_limit(gpu_source.width, gpu_source.height)
750 source = cls._prepare_source(gpu_source.source)
751 frame_idx = cls.compute_frames_index_to_sample(
752 source=source, target=target, **kwargs
@@ -835,6 +849,10 @@ class VideoBackend(
849
850 if backend == "opencv":
851 cap = cls.open_video_capture(data)
852 + _check_frame_pixel_limit(
853 + int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
854 + int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
855 + )
856 source = cls._prepare_source(cls.get_video_metadata(cap))
857 frame_idx = cls.compute_frames_index_to_sample(
858 source=source, target=target, **kwargs
@@ -850,6 +868,8 @@ class VideoBackend(
868 "frame_recovery is only available for `opencv` backend"
869 )
870 with av.open(BytesIO(data)) as container:
871 + stream = container.streams.video[0]
872 + _check_frame_pixel_limit(stream.width, stream.height)
873 source = cls._prepare_source(cls.get_metadata(container))
874 frame_idx = cls.compute_frames_index_to_sample(
875 source=source, target=target, **kwargs
@@ -1609,6 +1629,10 @@ class Molmo2VideoBackend(VideoLoader, OpenCVVideoBackendMixin):
1629 **kwargs,
1630 ) -> tuple[npt.NDArray, dict[str, Any]]:
1631 cap = cls.open_video_capture(data)
1632 + _check_frame_pixel_limit(
1633 + int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
1634 + int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
1635 + )
1636
1637 source = OpenCVVideoBackendMixin.get_video_metadata(cap)
1638 target = VideoTargetMetadata(
@@ -1758,6 +1782,10 @@ class OpenCVDynamicOpenPanguVideoBackend(VideoLoader, OpenCVVideoBackendMixin):
1782 Tuple of (frames_array, metadata_dict)
1783 """
1784 cap = cls.open_video_capture(data)
1785 + _check_frame_pixel_limit(
1786 + int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
1787 + int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
1788 + )
1789
1790 source = OpenCVVideoBackendMixin.get_video_metadata(cap)
1791
vllm/transformers_utils/processors/nano_nemotron_vl.py
-6
@@ -44,12 +44,6 @@ AUDIO_CONTEXT = "<so_embedding>"
44 # MAX_FRAMES = 16
45 DEFAULT_NUM_TILES = 12
46
47 -# Configure PIL to handle large images without warnings
48 -# This prevents DecompressionBombWarning for legitimate large images
49 -Image.MAX_IMAGE_PIXELS = None # Disable the limit entirely
50 -# Alternative: Set a specific higher limit
51 -# Image.MAX_IMAGE_PIXELS = 300000000 # ~300M pixels
52 -
47
48 def calculate_timestamps(
49 indices: list[int] | torch.Tensor,
vllm/transformers_utils/processors/nemotron_vl.py
-6
@@ -10,12 +10,6 @@ from vllm.tokenizers.hf import HfTokenizer
10
11 from .internvl import InternVLImageProcessor, InternVLProcessor
12
13 -# Configure PIL to handle large images without warnings
14 -# This prevents DecompressionBombWarning for legitimate large images
15 -Image.MAX_IMAGE_PIXELS = None # Disable the limit entirely
16 -# Alternative: Set a specific higher limit
17 -# Image.MAX_IMAGE_PIXELS = 300000000 # ~300M pixels
18 -
13
14 def build_transform(input_size: int):
15 return T.Compose(