A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 5, 21:00 UTC
(security) Fix SSRF in batch runner download_bytes_from_url (#38482)
Signed-off-by: jperezde <jperezde@redhat.com>
Juan Pérez de Algaba committed
Mar 30, 2026 at 09:10 UTC
57861ae48d3493fa48b4d7d830b7ec9f995783e7
3 files changed
+183
-8
docs/usage/security.md
+4
@@ -66,6 +66,10 @@ Restrict domains that vLLM can access for media URLs by setting
66
`--allowed-media-domains` to prevent Server-Side Request Forgery (SSRF) attacks.
67
(e.g. `--allowed-media-domains upload.wikimedia.org github.com www.bogotobogo.com`)
68
69
+This protection applies to both the online serving API (multimodal inputs) and
70
+the **batch runner** (`vllm run-batch`), where `file_url` values in batch
71
+transcription/translation requests are validated against the same allowlist.
72
+
73
Without domain restrictions, a malicious user could supply URLs that:
74
75
- **Target internal services**: Access internal network endpoints, cloud metadata
tests/entrypoints/openai/test_run_batch.py
+133
-1
@@ -4,11 +4,15 @@
4
import json
5
import subprocess
6
import tempfile
7
+from unittest.mock import AsyncMock, MagicMock, patch
8
9
import pytest
10
11
from vllm.assets.audio import AudioAsset
11
-from vllm.entrypoints.openai.run_batch import BatchRequestOutput
12
+from vllm.entrypoints.openai.run_batch import (
13
+ BatchRequestOutput,
14
+ download_bytes_from_url,
15
+)
16
17
CHAT_MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM"
18
EMBEDDING_MODEL_NAME = "intfloat/multilingual-e5-small"
@@ -746,3 +750,131 @@ def test_tool_calling():
750
assert "arguments" in tool_call["function"]
751
# Verify the tool name matches our tool definition
752
assert tool_call["function"]["name"] == "get_current_weather"
753
+
754
+
755
+# ---------------------------------------------------------------------------
756
+# Unit tests for download_bytes_from_url SSRF protection
757
+# ---------------------------------------------------------------------------
758
+
759
+
760
+def _make_aiohttp_mocks(response_data: bytes = b"fake-data", status: int = 200):
761
+ """Create mock objects that simulate aiohttp.ClientSession context managers."""
762
+ mock_resp = MagicMock()
763
+ mock_resp.status = status
764
+ mock_resp.read = AsyncMock(return_value=response_data)
765
+ mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
766
+ mock_resp.__aexit__ = AsyncMock(return_value=False)
767
+
768
+ mock_session = MagicMock()
769
+ mock_session.get = MagicMock(return_value=mock_resp)
770
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
771
+ mock_session.__aexit__ = AsyncMock(return_value=False)
772
+ return mock_session
773
+
774
+
775
+@pytest.mark.asyncio
776
+async def test_download_bytes_data_url_bypasses_domain_check():
777
+ """data: URLs must work regardless of the domain allowlist."""
778
+ data_url = f"data:audio/wav;base64,{MINIMAL_WAV_BASE64}"
779
+ result = await download_bytes_from_url(
780
+ data_url, allowed_media_domains=["example.com"]
781
+ )
782
+ assert isinstance(result, bytes)
783
+ assert len(result) > 0
784
+
785
+
786
+@pytest.mark.asyncio
787
+async def test_download_bytes_rejects_disallowed_domain():
788
+ """HTTP URLs whose hostname is not in the allowlist must be rejected."""
789
+ url = "https://evil.internal/secret"
790
+ with pytest.raises(ValueError, match="allowed domains"):
791
+ await download_bytes_from_url(url, allowed_media_domains=["example.com"])
792
+
793
+
794
+@pytest.mark.asyncio
795
+async def test_download_bytes_rejects_cloud_metadata_ip():
796
+ """Cloud metadata endpoints must be blocked when an allowlist is set."""
797
+ url = "http://169.254.169.254/latest/meta-data/"
798
+ with pytest.raises(ValueError, match="allowed domains"):
799
+ await download_bytes_from_url(url, allowed_media_domains=["example.com"])
800
+
801
+
802
+@pytest.mark.asyncio
803
+async def test_download_bytes_rejects_internal_ip():
804
+ """Private-range IPs must be blocked when an allowlist is set."""
805
+ for internal_url in [
806
+ "http://10.0.0.1/secret",
807
+ "http://192.168.1.1/admin",
808
+ "http://127.0.0.1:8080/internal",
809
+ ]:
810
+ with pytest.raises(ValueError, match="allowed domains"):
811
+ await download_bytes_from_url(
812
+ internal_url, allowed_media_domains=["example.com"]
813
+ )
814
+
815
+
816
+@pytest.mark.asyncio
817
+async def test_download_bytes_allows_permitted_domain():
818
+ """HTTP URLs whose hostname IS in the allowlist must be fetched."""
819
+ url = "https://example.com/audio.wav"
820
+ expected = b"audio-bytes"
821
+ mock_session = _make_aiohttp_mocks(expected)
822
+
823
+ with patch(
824
+ "vllm.entrypoints.openai.run_batch.aiohttp.ClientSession",
825
+ return_value=mock_session,
826
+ ):
827
+ result = await download_bytes_from_url(
828
+ url, allowed_media_domains=["example.com"]
829
+ )
830
+ assert result == expected
831
+
832
+
833
+@pytest.mark.asyncio
834
+async def test_download_bytes_no_allowlist_permits_any_domain():
835
+ """Without an allowlist all HTTP URLs must be attempted (backward compat)."""
836
+ url = "https://any-domain.example.org/file.wav"
837
+ expected = b"some-data"
838
+ mock_session = _make_aiohttp_mocks(expected)
839
+
840
+ with patch(
841
+ "vllm.entrypoints.openai.run_batch.aiohttp.ClientSession",
842
+ return_value=mock_session,
843
+ ):
844
+ result = await download_bytes_from_url(url, allowed_media_domains=None)
845
+ assert result == expected
846
+
847
+
848
+@pytest.mark.asyncio
849
+async def test_download_bytes_empty_allowlist_denies_all():
850
+ """An empty allowlist must deny all HTTP URLs (least privilege)."""
851
+ url = "https://any-domain.example.org/file.wav"
852
+ with pytest.raises(ValueError, match="allowed domains"):
853
+ await download_bytes_from_url(url, allowed_media_domains=[])
854
+
855
+
856
+@pytest.mark.asyncio
857
+async def test_download_bytes_unsupported_scheme():
858
+ """Unsupported URL schemes must be rejected regardless of allowlist."""
859
+ with pytest.raises(ValueError, match="Unsupported URL scheme"):
860
+ await download_bytes_from_url("ftp://example.com/file.wav")
861
+
862
+ with pytest.raises(ValueError, match="Unsupported URL scheme"):
863
+ await download_bytes_from_url(
864
+ "ftp://example.com/file.wav",
865
+ allowed_media_domains=["example.com"],
866
+ )
867
+
868
+
869
+@pytest.mark.asyncio
870
+async def test_download_bytes_backslash_bypass():
871
+ """Backslash-@ URL confusion must not bypass the allowlist.
872
+
873
+ urllib3.parse_url() and aiohttp/yarl disagree on backslash-before-@.
874
+ The fix normalizes through urllib3 before handing to aiohttp.
875
+ """
876
+ bypass_url = "http://allowed.example.com\\@evil.internal/secret"
877
+ with pytest.raises(ValueError, match="allowed domains"):
878
+ await download_bytes_from_url(
879
+ bypass_url, allowed_media_domains=["evil.internal"]
880
+ )
vllm/entrypoints/openai/run_batch.py
+46
-7
@@ -20,7 +20,9 @@ from pydantic import Field, TypeAdapter, field_validator, model_validator
20
from pydantic_core.core_schema import ValidationInfo
21
from starlette.datastructures import State
22
from tqdm import tqdm
23
+from urllib3.util import parse_url
24
25
+import vllm.envs as envs
26
from vllm.config import config
27
from vllm.engine.arg_utils import AsyncEngineArgs
28
from vllm.engine.protocol import EngineClient
@@ -439,19 +441,25 @@ async def write_file(
441
await write_local_file(path_or_url, batch_outputs)
442
443
442
-async def download_bytes_from_url(url: str) -> bytes:
444
+async def download_bytes_from_url(
445
+ url: str,
446
+ allowed_media_domains: list[str] | None = None,
447
+) -> bytes:
448
"""
449
Download data from a URL or decode from a data URL.
450
451
Args:
452
url: Either an HTTP/HTTPS URL or a data URL (data:...;base64,...)
453
+ allowed_media_domains: If set, only HTTP/HTTPS URLs whose hostname
454
+ is in this list are permitted. data: URLs are not subject to
455
+ this restriction.
456
457
Returns:
458
Data as bytes
459
"""
460
parsed = urlparse(url)
461
454
- # Handle data URLs (base64 encoded)
462
+ # Handle data URLs (base64 encoded) - not subject to domain restrictions
463
if parsed.scheme == "data":
464
# Format: data:...;base64,<base64_data>
465
if "," in url:
@@ -465,9 +473,24 @@ async def download_bytes_from_url(url: str) -> bytes:
473
474
# Handle HTTP/HTTPS URLs
475
elif parsed.scheme in ("http", "https"):
476
+ if allowed_media_domains is not None:
477
+ url_spec = parse_url(url)
478
+ if url_spec.hostname not in allowed_media_domains:
479
+ raise ValueError(
480
+ f"The URL must be from one of the allowed domains: "
481
+ f"{allowed_media_domains}. Input URL domain: "
482
+ f"{url_spec.hostname}"
483
+ )
484
+ # Use the normalized URL to prevent parsing discrepancies
485
+ # between urllib3 and aiohttp (e.g. backslash-@ attacks).
486
+ url = url_spec.url
487
+
488
async with (
489
aiohttp.ClientSession() as session,
470
- session.get(url) as resp,
490
+ session.get(
491
+ url,
492
+ allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
493
+ ) as resp,
494
):
495
if resp.status != 200:
496
raise Exception(
@@ -593,7 +616,10 @@ def handle_endpoint_request(
616
return run_request(handler_fn, request, tracker)
617
618
596
-def make_transcription_wrapper(is_translation: bool) -> WrapperFn:
619
+def make_transcription_wrapper(
620
+ is_translation: bool,
621
+ allowed_media_domains: list[str] | None = None,
622
+) -> WrapperFn:
623
"""
624
Factory function to create a wrapper for transcription/translation handlers.
625
The wrapper converts BatchTranscriptionRequest or BatchTranslationRequest
@@ -602,6 +628,8 @@ def make_transcription_wrapper(is_translation: bool) -> WrapperFn:
628
Args:
629
is_translation: If True, process as translation; otherwise process
630
as transcription
631
+ allowed_media_domains: If set, only URLs from these domains are
632
+ permitted for HTTP/HTTPS fetches.
633
634
Returns:
635
A function that takes a handler and returns a wrapped handler
@@ -619,7 +647,10 @@ def make_transcription_wrapper(is_translation: bool) -> WrapperFn:
647
):
648
try:
649
# Download data from URL
622
- audio_data = await download_bytes_from_url(batch_request_body.file_url)
650
+ audio_data = await download_bytes_from_url(
651
+ batch_request_body.file_url,
652
+ allowed_media_domains=allowed_media_domains,
653
+ )
654
655
# Create a mock file from the downloaded audio data
656
mock_file = UploadFile(
@@ -691,6 +722,8 @@ async def build_endpoint_registry(
722
serving_embedding = getattr(state, "serving_embedding", None)
723
serving_scores = getattr(state, "serving_scores", None)
724
725
+ allowed_media_domains = getattr(args, "allowed_media_domains", None)
726
+
727
# Registry of endpoint configurations
728
endpoint_registry: dict[str, dict[str, Any]] = {
729
"completions": {
@@ -730,7 +763,10 @@ async def build_endpoint_registry(
763
if openai_serving_transcription is not None
764
else None
765
),
733
- "wrapper_fn": make_transcription_wrapper(is_translation=False),
766
+ "wrapper_fn": make_transcription_wrapper(
767
+ is_translation=False,
768
+ allowed_media_domains=allowed_media_domains,
769
+ ),
770
},
771
"translations": {
772
"url_matcher": lambda url: url == "/v1/audio/translations",
@@ -739,7 +775,10 @@ async def build_endpoint_registry(
775
if openai_serving_translation is not None
776
else None
777
),
742
- "wrapper_fn": make_transcription_wrapper(is_translation=True),
778
+ "wrapper_fn": make_transcription_wrapper(
779
+ is_translation=True,
780
+ allowed_media_domains=allowed_media_domains,
781
+ ),
782
},
783
}
784