[Frontend] Use VLLMValidationError for batch request URL validation (#50191)

Signed-off-by: Chao Tan <chaos.tc@gmail.com>

Tan Chao committed Aug 21, 2026 at 09:07 UTC 1baf372bfc14d739860b4a7122877e22ef0dcbf1
2 files changed +36 -13
tests/entrypoints/openai/test_run_batch.py
+20 -7
@@ -13,6 +13,7 @@ from vllm.entrypoints.openai.run_batch import (
13 BatchRequestOutput,
14 download_bytes_from_url,
15 )
16 +from vllm.exceptions import VLLMValidationError
17
18 CHAT_MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM"
19 EMBEDDING_MODEL_NAME = "intfloat/multilingual-e5-small"
@@ -788,15 +789,27 @@ async def test_download_bytes_data_url_bypasses_domain_check():
789 async def test_download_bytes_rejects_disallowed_domain():
790 """HTTP URLs whose hostname is not in the allowlist must be rejected."""
791 url = "https://evil.internal/secret"
791 - with pytest.raises(ValueError, match="allowed domains"):
792 + with pytest.raises(VLLMValidationError, match="allowed domains") as exc_info:
793 await download_bytes_from_url(url, allowed_media_domains=["example.com"])
794 + # URL validation failures carry structured metadata for the frontend.
795 + assert exc_info.value.parameter == "url"
796 + assert exc_info.value.value == "evil.internal"
797 +
798 +
799 +@pytest.mark.asyncio
800 +async def test_download_bytes_rejects_unsupported_scheme():
801 + """Unsupported URL schemes are rejected with structured metadata."""
802 + with pytest.raises(VLLMValidationError, match="Unsupported URL scheme") as exc_info:
803 + await download_bytes_from_url("ftp://example.com/file")
804 + assert exc_info.value.parameter == "url"
805 + assert exc_info.value.value == "ftp"
806
807
808 @pytest.mark.asyncio
809 async def test_download_bytes_rejects_cloud_metadata_ip():
810 """Cloud metadata endpoints must be blocked when an allowlist is set."""
811 url = "http://169.254.169.254/latest/meta-data/"
799 - with pytest.raises(ValueError, match="allowed domains"):
812 + with pytest.raises(VLLMValidationError, match="allowed domains"):
813 await download_bytes_from_url(url, allowed_media_domains=["example.com"])
814
815
@@ -808,7 +821,7 @@ async def test_download_bytes_rejects_internal_ip():
821 "http://192.168.1.1/admin",
822 "http://127.0.0.1:8080/internal",
823 ]:
811 - with pytest.raises(ValueError, match="allowed domains"):
824 + with pytest.raises(VLLMValidationError, match="allowed domains"):
825 await download_bytes_from_url(
826 internal_url, allowed_media_domains=["example.com"]
827 )
@@ -850,17 +863,17 @@ async def test_download_bytes_no_allowlist_permits_any_domain():
863 async def test_download_bytes_empty_allowlist_denies_all():
864 """An empty allowlist must deny all HTTP URLs (least privilege)."""
865 url = "https://any-domain.example.org/file.wav"
853 - with pytest.raises(ValueError, match="allowed domains"):
866 + with pytest.raises(VLLMValidationError, match="allowed domains"):
867 await download_bytes_from_url(url, allowed_media_domains=[])
868
869
870 @pytest.mark.asyncio
871 async def test_download_bytes_unsupported_scheme():
872 """Unsupported URL schemes must be rejected regardless of allowlist."""
860 - with pytest.raises(ValueError, match="Unsupported URL scheme"):
873 + with pytest.raises(VLLMValidationError, match="Unsupported URL scheme"):
874 await download_bytes_from_url("ftp://example.com/file.wav")
875
863 - with pytest.raises(ValueError, match="Unsupported URL scheme"):
876 + with pytest.raises(VLLMValidationError, match="Unsupported URL scheme"):
877 await download_bytes_from_url(
878 "ftp://example.com/file.wav",
879 allowed_media_domains=["example.com"],
@@ -875,7 +888,7 @@ async def test_download_bytes_backslash_bypass():
888 The fix normalizes through urllib3 before handing to aiohttp.
889 """
890 bypass_url = "http://allowed.example.com\\@evil.internal/secret"
878 - with pytest.raises(ValueError, match="allowed domains"):
891 + with pytest.raises(VLLMValidationError, match="allowed domains"):
892 await download_bytes_from_url(
893 bypass_url, allowed_media_domains=["evil.internal"]
894 )
vllm/entrypoints/openai/run_batch.py
+16 -6
@@ -476,19 +476,27 @@ async def download_bytes_from_url(
476 if "base64" in header:
477 return base64.b64decode(data)
478 else:
479 - raise ValueError(f"Unsupported data URL encoding: {header}")
479 + raise VLLMValidationError(
480 + f"Unsupported data URL encoding: {header}",
481 + parameter="url",
482 + )
483 else:
481 - raise ValueError(f"Invalid data URL format: {url}")
484 + raise VLLMValidationError(
485 + f"Invalid data URL format: {url}",
486 + parameter="url",
487 + )
488
489 # Handle HTTP/HTTPS URLs
490 elif parsed.scheme in ("http", "https"):
491 if allowed_media_domains is not None:
492 url_spec = parse_url(url)
493 if url_spec.hostname not in allowed_media_domains:
488 - raise ValueError(
494 + raise VLLMValidationError(
495 f"The URL must be from one of the allowed domains: "
496 f"{allowed_media_domains}. Input URL domain: "
491 - f"{url_spec.hostname}"
497 + f"{url_spec.hostname}",
498 + parameter="url",
499 + value=url_spec.hostname,
500 )
501 # Use the normalized URL to prevent parsing discrepancies
502 # between urllib3 and aiohttp (e.g. backslash-@ attacks).
@@ -499,9 +507,11 @@ async def download_bytes_from_url(
507 )
508
509 else:
502 - raise ValueError(
510 + raise VLLMValidationError(
511 f"Unsupported URL scheme: {parsed.scheme}. "
504 - "Supported schemes: http, https, data"
512 + "Supported schemes: http, https, data",
513 + parameter="url",
514 + value=parsed.scheme,
515 )
516
517