diff --git a/docs/docs/advanced_features/server_arguments.mdx b/docs/docs/advanced_features/server_arguments.mdx index ed6b57f59..698ef417d 100644 --- a/docs/docs/advanced_features/server_arguments.mdx +++ b/docs/docs/advanced_features/server_arguments.mdx @@ -56,6 +56,15 @@ You can find all arguments by `python3 -m sglang.launch_server --help` - To enable fp8 weight quantization, add `--quantization fp8` on a fp16 checkpoint or directly load a fp8 checkpoint without specifying any arguments. - To enable fp8 kv cache quantization, add `--kv-cache-dtype fp8_e4m3` or `--kv-cache-dtype fp8_e5m2`. - To enable deterministic inference and batch invariant operations, add `--enable-deterministic-inference`. More details can be found in [deterministic inference document](./deterministic_inference). +- If a multimodal server accepts requests from untrusted clients, restrict remote image, video, and audio URLs with `--allowed-media-domains`. SGLang checks the initial URL and every redirect destination against the exact-hostname allowlist. Remote media downloads are limited to 64 MiB by default; adjust `--media-url-max-file-size-mb` when larger trusted media is required. + + ```bash Command + python -m sglang.launch_server \ + --model-path Qwen/Qwen2.5-VL-7B-Instruct \ + --allowed-media-domains upload.wikimedia.org raw.githubusercontent.com + ``` + + Without `--allowed-media-domains`, HTTP(S) media from any domain remains allowed for backward compatibility. Do not expose that configuration to untrusted users. Local paths and `data:` URLs are not governed by the domain allowlist. - To enable decode context parallelism for MLA models, add `--dcp-size N`. See [Decode Context Parallelism](./dcp). - If the model does not have a chat template in the Hugging Face tokenizer, you can specify a [custom chat template](../references/custom_chat_template). If the tokenizer has multiple named templates (e.g., 'default', 'tool_use'), you can select one using `--hf-chat-template-name tool_use`. - To run tensor parallelism on multiple nodes, add `--nnodes 2`. If you have two nodes with two GPUs on each node and want to run TP=4, let `sgl-dev-0` be the hostname of the first node and `50000` be an available port, you can use the following commands. If you meet deadlock, please try to add `--disable-cuda-graph` @@ -3293,6 +3302,18 @@ Please consult the documentation below and [server_args.py](https://github.com/s {} Type: JSON / Dict + + `--allowed-media-domains` + Restrict client-supplied HTTP(S) media URLs and redirect destinations to these exact hostnames. + Unrestricted + Space-separated hostnames + + + `--media-url-max-file-size-mb` + Maximum streamed size in MiB for one remote media download. Set to 0 to disable the limit. + `64` + Type: int + `--mm-enable-dp-encoder` Enabling data parallelism for mm encoder. The dp size will be set to the tp size automatically. diff --git a/python/sglang/srt/disaggregation/encode_server.py b/python/sglang/srt/disaggregation/encode_server.py index a1ac9af65..af6a3ffef 100644 --- a/python/sglang/srt/disaggregation/encode_server.py +++ b/python/sglang/srt/disaggregation/encode_server.py @@ -85,6 +85,7 @@ from sglang.srt.utils import ( CLIENT_MEDIA_EXCEPTIONS, add_prometheus_middleware, configure_logger, + configure_media_url_security, load_audio, load_image, load_video, @@ -308,6 +309,10 @@ class MMEncoder: argument.""" logger.info(f"init MMEncoder {rank}/{server_args.tp_size}") self.server_args = server_args + configure_media_url_security( + server_args.allowed_media_domains, + server_args.media_url_max_file_size_mb, + ) publish(server_args, role="encoder") self.rank = rank # DP rank for metric labels; overridden by run_dp_worker in DP mode. diff --git a/python/sglang/srt/multimodal/processors/base_processor.py b/python/sglang/srt/multimodal/processors/base_processor.py index 61339c39b..01c60f854 100644 --- a/python/sglang/srt/multimodal/processors/base_processor.py +++ b/python/sglang/srt/multimodal/processors/base_processor.py @@ -32,6 +32,7 @@ from sglang.srt.multimodal.transport.cuda_ipc import ( ) from sglang.srt.utils import ( CLIENT_MEDIA_EXCEPTIONS, + configure_media_url_security, envs, is_cpu, is_npu, @@ -199,6 +200,10 @@ class BaseMultimodalProcessor(ABC): self._processor = _processor self.server_args = server_args self.transport_mode = transport_mode + configure_media_url_security( + server_args.allowed_media_domains, + server_args.media_url_max_file_size_mb, + ) configured_mm_feature_transport = getattr( server_args, "mm_feature_transport", "cpu" ) diff --git a/python/sglang/srt/multimodal/processors/inkling.py b/python/sglang/srt/multimodal/processors/inkling.py index 88df4efe0..8750e2912 100644 --- a/python/sglang/srt/multimodal/processors/inkling.py +++ b/python/sglang/srt/multimodal/processors/inkling.py @@ -24,7 +24,6 @@ from __future__ import annotations import base64 import logging -import urllib.request from collections.abc import Mapping from typing import Any, Dict, List, Optional, Tuple, Type, Union @@ -53,6 +52,7 @@ from sglang.srt.parser.inkling_tokenizer import IMAGE_TOKEN_ID as INKLING_IMAGE_ from sglang.srt.parser.inkling_tokenizer import ( INKLING_SPECIAL_TOKEN_IDS, ) +from sglang.srt.utils.common import download_remote_media logger = logging.getLogger(__name__) @@ -82,8 +82,7 @@ def _resolve_media_item(item): header, _, payload = url.partition(",") return base64.b64decode(payload) if ";base64" in header else payload.encode() if url.startswith(("http://", "https://")): - with urllib.request.urlopen(url, timeout=30) as resp: - return resp.read() + return download_remote_media(url, timeout=30) return url # plain path / file:// -> handled by the per-modality byte loader diff --git a/python/sglang/srt/multimodal/processors/mimo_audio.py b/python/sglang/srt/multimodal/processors/mimo_audio.py index 47295e9a8..a0f727e49 100644 --- a/python/sglang/srt/multimodal/processors/mimo_audio.py +++ b/python/sglang/srt/multimodal/processors/mimo_audio.py @@ -186,18 +186,14 @@ class MiMoAudioPipeline: dl_start = time.perf_counter() timeout = int(os.getenv("REQUEST_TIMEOUT", "5")) try: - with common.get_mm_http_session().get( - audio, stream=True, timeout=timeout - ) as response: - response.raise_for_status() - dl_elapsed_ms = (time.perf_counter() - dl_start) * 1000 - if dl_elapsed_ms > 1000.0: - content_len = len(response.content) - logger.warning( - f"Slow audio download: {dl_elapsed_ms:.2f}ms, " - f"size={content_len / 1024:.1f}KB, url={audio}" - ) - file = io.BytesIO(response.content) + content = common.download_remote_media(audio, timeout=timeout) + dl_elapsed_ms = (time.perf_counter() - dl_start) * 1000 + if dl_elapsed_ms > 1000.0: + logger.warning( + f"Slow audio download: {dl_elapsed_ms:.2f}ms, " + f"size={len(content) / 1024:.1f}KB, url={audio}" + ) + file = io.BytesIO(content) except Exception as e: dl_elapsed_ms = (time.perf_counter() - dl_start) * 1000 logger.error( diff --git a/python/sglang/srt/multimodal/processors/mimo_v2.py b/python/sglang/srt/multimodal/processors/mimo_v2.py index 91397692f..cd9f5fc47 100644 --- a/python/sglang/srt/multimodal/processors/mimo_v2.py +++ b/python/sglang/srt/multimodal/processors/mimo_v2.py @@ -5,6 +5,7 @@ import base64 import copy import json import math +import os import re import subprocess from concurrent.futures import ThreadPoolExecutor, as_completed @@ -13,7 +14,6 @@ from io import BytesIO from typing import List, Literal, Optional, Union import numpy as np -import requests import torch import torch.nn.functional as F from fastapi import HTTPException @@ -40,6 +40,7 @@ from sglang.srt.multimodal.processors.mimo_audio import ( ) from sglang.srt.multimodal.processors.qwen_vl import smart_nframes from sglang.srt.utils import ImageData, VideoData +from sglang.srt.utils.common import download_remote_media from sglang.utils import logger @@ -485,12 +486,14 @@ class MiMoProcessor: @staticmethod def has_audio_track(path_or_data) -> bool: - # In-process probe via torchcodec for bytes/path; ffprobe range - # request for HTTP URLs so we do not pre-download the blob here. + # Never hand a client-supplied URL to ffprobe: its internal HTTP client + # would bypass the shared domain and redirect policy. Resolve it through + # the guarded downloader first, then probe the resulting bytes in-process. if isinstance(path_or_data, str) and path_or_data.startswith( ("http://", "https://") ): - return _ffprobe_has_audio(path_or_data, stdin=None, label=path_or_data) + timeout = int(os.getenv("REQUEST_TIMEOUT", "10")) + path_or_data = download_remote_media(path_or_data, timeout=timeout) if isinstance(path_or_data, bytes): source = BytesIO(path_or_data) @@ -1446,10 +1449,8 @@ class MiMoProcessor: image_obj = image elif isinstance(image, str): if image.startswith("http://") or image.startswith("https://"): - with requests.get(image, stream=True) as response: - response.raise_for_status() - with BytesIO(response.content) as bio: - image_obj = copy.deepcopy(Image.open(bio)) + with BytesIO(download_remote_media(image, timeout=3)) as bio: + image_obj = copy.deepcopy(Image.open(bio)) elif image.startswith("file://"): image_obj = Image.open(image[7:]) elif image.startswith("data:image"): diff --git a/python/sglang/srt/multimodal/processors/moss_vl.py b/python/sglang/srt/multimodal/processors/moss_vl.py index 9241fbf86..8df4655b9 100644 --- a/python/sglang/srt/multimodal/processors/moss_vl.py +++ b/python/sglang/srt/multimodal/processors/moss_vl.py @@ -6,7 +6,6 @@ from typing import Dict, List, Optional, Tuple, Union from urllib.parse import unquote, urlparse import pybase64 -import requests import torch from sglang.srt.managers.schedule_batch import ( @@ -21,6 +20,7 @@ from sglang.srt.multimodal.processors.base_processor import ( from sglang.srt.multimodal.processors.base_processor import ( MultimodalSpecialTokens, ) +from sglang.srt.utils.common import download_remote_media class MossVLImageProcessor(SGLangBaseProcessor): @@ -426,13 +426,10 @@ class MossVLImageProcessor(SGLangBaseProcessor): if value.startswith(("http://", "https://")): timeout = int(os.getenv("REQUEST_TIMEOUT", "10")) - response = requests.get(value, stream=True, timeout=timeout) - response.raise_for_status() + content = download_remote_media(value, timeout=timeout) suffix = os.path.splitext(urlparse(value).path)[1] or ".mp4" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) + f.write(content) return f.name, f.name if value.startswith("data:"): diff --git a/python/sglang/srt/server_args.py b/python/sglang/srt/server_args.py index 6d6625fe0..0eca8eee2 100644 --- a/python/sglang/srt/server_args.py +++ b/python/sglang/srt/server_args.py @@ -70,6 +70,7 @@ from sglang.srt.speculative.decoupled_spec_io import DecoupledSpecIpcConfig from sglang.srt.utils.common import ( LORA_TARGET_ALL_MODULES, SUPPORTED_LORA_TARGET_MODULES, + configure_media_url_security, get_device, get_device_memory_capacity, get_device_sm, @@ -2764,6 +2765,19 @@ class ServerArgs: "environment override when this argument is 0.", NS("mm"), ] = 0 + allowed_media_domains: A[ + List[str], + "Restrict client-supplied HTTP(S) image, video, and audio URLs to these " + "exact hostnames. Redirect destinations are checked against the same " + "allowlist. When unset, remote media from any domain is allowed.", + NS("mm"), + ] = dataclasses.field(default_factory=list) + media_url_max_file_size_mb: A[ + int, + "Maximum size in MiB for one client-supplied remote media download. " + "The limit is enforced while streaming; set to 0 to disable it.", + NS("mm"), + ] = 64 mm_preprocess_cache_size_mb: A[ Optional[int], "CPU memory budget for content-addressed multimodal preprocessing " @@ -3561,6 +3575,7 @@ class ServerArgs: self._handle_moe_runner_backend_alias() self._handle_return_hidden_states_mode() + self._handle_media_url_security() if self.model_path.lower() in ["none", "dummy"]: return @@ -4051,6 +4066,13 @@ class ServerArgs: f"but got {type(self.mm_process_config[key])}" ) + def _handle_media_url_security(self): + """Normalize and publish the media URL policy before workers start.""" + self.allowed_media_domains = configure_media_url_security( + self.allowed_media_domains, + self.media_url_max_file_size_mb, + ) + def _handle_deprecated_args(self): if self.disable_fast_image_processor: if self.image_processor_backend not in {"auto", "pil"}: diff --git a/python/sglang/srt/utils/common.py b/python/sglang/srt/utils/common.py index efdd0097f..33de99401 100644 --- a/python/sglang/srt/utils/common.py +++ b/python/sglang/srt/utils/common.py @@ -25,6 +25,7 @@ import gc import importlib import inspect import io +import ipaddress import itertools import json import logging @@ -75,7 +76,7 @@ from typing import ( ) from unittest import SkipTest from unittest.case import _ShouldStop -from urllib.parse import unquote, urlparse +from urllib.parse import unquote, urljoin, urlparse import numpy as np import orjson @@ -1510,6 +1511,158 @@ def set_random_seed(seed: int) -> None: _mm_http_session = threading.local() +_DEFAULT_MEDIA_URL_MAX_FILE_SIZE_MB = 64 +_MAX_MEDIA_URL_REDIRECTS = 5 +_MEDIA_URL_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308} +_allowed_media_domains: frozenset[str] = frozenset() +_media_url_max_file_size_bytes = _DEFAULT_MEDIA_URL_MAX_FILE_SIZE_MB * 1024 * 1024 + + +def _normalize_media_domain(domain: str) -> str: + if not isinstance(domain, str): + raise ValueError("allowed media domains must be strings") + + domain = domain.strip().rstrip(".") + if not domain: + raise ValueError("allowed media domains cannot be empty") + if "://" in domain or any(char in domain for char in "/?#@"): + raise ValueError( + f"Invalid allowed media domain {domain!r}: provide a hostname only" + ) + + # Brackets are URL syntax, not part of an IPv6 hostname. + if domain.startswith("[") and domain.endswith("]"): + domain = domain[1:-1] + try: + return str(ipaddress.ip_address(domain)) + except ValueError: + if ":" in domain: + raise ValueError( + f"Invalid allowed media domain {domain!r}: ports are not supported" + ) + + try: + normalized = domain.encode("idna").decode("ascii").lower() + except UnicodeError as e: + raise ValueError(f"Invalid allowed media domain {domain!r}") from e + if not normalized: + raise ValueError("allowed media domains cannot be empty") + return normalized + + +def configure_media_url_security( + allowed_media_domains: Optional[Sequence[str]] = None, + max_file_size_mb: int = _DEFAULT_MEDIA_URL_MAX_FILE_SIZE_MB, +) -> list[str]: + """Configure process-wide safeguards for client-supplied media URLs. + + A serving worker hosts one engine configuration, while media loading fans + out to worker threads. Keeping the immutable policy here makes the same + checks apply to image, video, audio, cache, and model-specific loaders. + """ + + if max_file_size_mb < 0: + raise ValueError("media_url_max_file_size_mb must be non-negative") + + normalized_domains = sorted( + {_normalize_media_domain(domain) for domain in allowed_media_domains or []} + ) + global _allowed_media_domains, _media_url_max_file_size_bytes + _allowed_media_domains = frozenset(normalized_domains) + _media_url_max_file_size_bytes = max_file_size_mb * 1024 * 1024 + return normalized_domains + + +def _assert_media_url_allowed(url: str) -> None: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or parsed.hostname is None: + raise ValueError(f"Invalid media URL: {url!r}") + + hostname = _normalize_media_domain(parsed.hostname) + if _allowed_media_domains and hostname not in _allowed_media_domains: + raise ValueError( + "Media URL domain is not allowed. " + f"Allowed domains: {sorted(_allowed_media_domains)}; " + f"input domain: {hostname}" + ) + + +def download_remote_media(url: str, timeout: float) -> bytes: + """Download one HTTP(S) media object under the configured URL policy. + + Redirects are followed manually so every destination is validated before + a connection is made. The response is streamed to enforce both the total + request deadline and the configured byte limit without first buffering an + attacker-controlled body in memory. + """ + + if timeout <= 0: + raise ValueError("media URL timeout must be positive") + + session = get_mm_http_session() + deadline = time.monotonic() + timeout + current_url = url + + for redirect_count in range(_MAX_MEDIA_URL_REDIRECTS + 1): + # Validate the same normalized URL representation that requests sends + # to urllib3. This avoids parser disagreements around backslashes and + # userinfo separators. + prepared_url = requests.Request("GET", current_url).prepare().url + if prepared_url is None: + raise ValueError(f"Invalid media URL: {current_url!r}") + _assert_media_url_allowed(prepared_url) + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise requests.exceptions.Timeout( + f"Timed out while downloading media URL: {url}" + ) + + with session.get( + prepared_url, + allow_redirects=False, + stream=True, + timeout=remaining, + ) as response: + location = response.headers.get("Location") + if response.status_code in _MEDIA_URL_REDIRECT_STATUS_CODES and location: + if redirect_count == _MAX_MEDIA_URL_REDIRECTS: + raise requests.exceptions.TooManyRedirects( + f"Media URL exceeded {_MAX_MEDIA_URL_REDIRECTS} redirects: {url}" + ) + current_url = urljoin(response.url, location) + continue + + response.raise_for_status() + max_bytes = _media_url_max_file_size_bytes + content_length = response.headers.get("Content-Length") + if max_bytes and content_length is not None: + try: + declared_size = int(content_length) + except ValueError: + declared_size = None + if declared_size is not None and declared_size > max_bytes: + raise ValueError( + f"Remote media exceeds the {max_bytes} byte download limit" + ) + + content = bytearray() + for chunk in response.iter_content(chunk_size=64 * 1024): + if not chunk: + continue + if time.monotonic() > deadline: + raise requests.exceptions.Timeout( + f"Timed out while downloading media URL: {url}" + ) + if max_bytes and len(content) + len(chunk) > max_bytes: + raise ValueError( + f"Remote media exceeds the {max_bytes} byte download limit" + ) + content.extend(chunk) + return bytes(content) + + raise AssertionError("unreachable") + def get_mm_http_session() -> requests.Session: """Per-thread HTTP session for multimodal downloads, to pool/reuse TCP @@ -1548,9 +1701,7 @@ def load_audio( audio_file.startswith("http://") or audio_file.startswith("https://") ): timeout = int(os.getenv("REQUEST_TIMEOUT", "5")) - with get_mm_http_session().get(audio_file, timeout=timeout) as response: - response.raise_for_status() - source = response.content + source = download_remote_media(audio_file, timeout=timeout) elif isinstance(audio_file, str) and audio_file.startswith("file://"): source = unquote(urlparse(audio_file).path) elif isinstance(audio_file, str): @@ -1753,13 +1904,7 @@ def get_image_bytes(image_file: Union[str, bytes]) -> bytes: return image_file if image_file.startswith(("http://", "https://")): timeout = int(os.getenv("REQUEST_TIMEOUT", "3")) - response = get_mm_http_session().get(image_file, timeout=timeout) - try: - response.raise_for_status() - result = response.content - finally: - response.close() - return result + return download_remote_media(image_file, timeout=timeout) if image_file.startswith(("file://", "/")): with open(image_file, "rb") as f: return f.read() @@ -1785,11 +1930,7 @@ def _normalize_video_input( elif isinstance(video_file, str): if video_file.startswith(("http://", "https://")): timeout = int(os.getenv("REQUEST_TIMEOUT", "10")) - with get_mm_http_session().get( - video_file, stream=True, timeout=timeout - ) as response: - response.raise_for_status() - return response.content + return download_remote_media(video_file, timeout=timeout) elif video_file.startswith("data:"): _, encoded = video_file.split(",", 1) return pybase64.b64decode(encoded, validate=True) diff --git a/test/registered/unit/managers/test_mm_process_config.py b/test/registered/unit/managers/test_mm_process_config.py index 3e665f9e0..ef0593b77 100644 --- a/test/registered/unit/managers/test_mm_process_config.py +++ b/test/registered/unit/managers/test_mm_process_config.py @@ -87,6 +87,8 @@ class TestBaseProcessorConfigExtraction(CustomTestCase): server_args.mm_preprocess_cache_size_mb = None server_args.tokenizer_worker_num = 1 server_args.trust_mm_content_hashes = False + server_args.allowed_media_domains = [] + server_args.media_url_max_file_size_mb = 64 hf_config = MagicMock() mock_hf_processor = MagicMock() @@ -178,6 +180,8 @@ class TestMultimodalFeatureTransportRuntime(CustomTestCase): tokenizer_worker_num=1, base_gpu_id=2, tp_size=8, + allowed_media_domains=[], + media_url_max_file_size_mb=64, ) @staticmethod @@ -773,6 +777,8 @@ class TestDoubleBosGuard(CustomTestCase): server_args.mm_preprocess_cache_size_mb = None server_args.tokenizer_worker_num = 1 server_args.trust_mm_content_hashes = False + server_args.allowed_media_domains = [] + server_args.media_url_max_file_size_mb = 64 mock_hf_processor = MagicMock() mock_hf_processor.__class__.__name__ = "TestProcessor" diff --git a/test/registered/unit/models/test_kimi_k25.py b/test/registered/unit/models/test_kimi_k25.py index 3cdbf0f52..bac86d8a2 100644 --- a/test/registered/unit/models/test_kimi_k25.py +++ b/test/registered/unit/models/test_kimi_k25.py @@ -599,6 +599,8 @@ def test_kimi_processor_workers_clone_the_gpu_wrapper(processor_cls, wrapper_cls mm_processor_worker_num=0, tokenizer_worker_num=1, base_gpu_id=0, + allowed_media_domains=[], + media_url_max_file_size_mb=64, ) processor = processor_cls( hf_config=SimpleNamespace(media_placeholder_token_id=42), diff --git a/test/registered/unit/multimodal/rust/qwen/_fixtures.py b/test/registered/unit/multimodal/rust/qwen/_fixtures.py index e1bfc6d31..6b5b55f63 100644 --- a/test/registered/unit/multimodal/rust/qwen/_fixtures.py +++ b/test/registered/unit/multimodal/rust/qwen/_fixtures.py @@ -80,6 +80,8 @@ def make_processor(config, image_processor_cls=None): mm_processor_worker_num=1, tokenizer_worker_num=1, base_gpu_id=0, + allowed_media_domains=[], + media_url_max_file_size_mb=64, ) return QwenVLImageProcessor( hf_config, server_args, processor, None, skip_mm_pool=True diff --git a/test/registered/unit/multimodal/test_media_url_security.py b/test/registered/unit/multimodal/test_media_url_security.py new file mode 100644 index 000000000..07da63cbc --- /dev/null +++ b/test/registered/unit/multimodal/test_media_url_security.py @@ -0,0 +1,183 @@ +"""Security tests for client-supplied remote multimodal media URLs.""" + +import http.server +import threading +import unittest +from unittest.mock import patch + +import requests + +from sglang.srt.utils.common import ( + _normalize_video_input, + configure_media_url_security, + download_remote_media, + get_image_bytes, + load_audio, +) +from sglang.test.ci.ci_register import register_cpu_ci + +register_cpu_ci(est_time=10, suite="base-a-test-cpu") + + +class _MediaHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/media": + payload = b"remote-media" + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + + if self.path == "/same-host-redirect": + self.send_response(302) + self.send_header("Location", "/media") + self.end_headers() + return + + if self.path == "/other-host-redirect": + self.send_response(302) + self.send_header( + "Location", + f"http://localhost:{self.server.server_port}/redirect-target", + ) + self.end_headers() + return + + if self.path == "/redirect-target": + self.server.redirect_target_reached = True + self.send_response(200) + self.end_headers() + self.wfile.write(b"must-not-be-fetched") + return + + if self.path == "/oversized": + self.send_response(200) + self.send_header("Content-Length", str(2 * 1024 * 1024)) + self.end_headers() + return + + if self.path == "/chunked-oversized": + self.send_response(200) + self.end_headers() + self.wfile.write(b"x" * (1024 * 1024 + 1)) + return + + if self.path == "/redirect-loop": + self.send_response(302) + self.send_header("Location", "/redirect-loop") + self.end_headers() + return + + self.send_response(404) + self.end_headers() + + def log_message(self, *_): + pass + + +class TestMediaURLSecurity(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _MediaHandler) + cls.server.redirect_target_reached = False + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.port = cls.server.server_port + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join() + + def setUp(self): + self.server.redirect_target_reached = False + configure_media_url_security([], max_file_size_mb=64) + + def tearDown(self): + configure_media_url_security([], max_file_size_mb=64) + + def _url(self, path, host="127.0.0.1"): + return f"http://{host}:{self.port}{path}" + + def test_unrestricted_mode_preserves_remote_media_compatibility(self): + self.assertEqual( + download_remote_media(self._url("/media", host="localhost"), timeout=5), + b"remote-media", + ) + + def test_exact_domain_allowlist(self): + configure_media_url_security(["127.0.0.1"], max_file_size_mb=64) + self.assertEqual( + download_remote_media(self._url("/media"), timeout=5), b"remote-media" + ) + with self.assertRaisesRegex(ValueError, "not allowed"): + download_remote_media(self._url("/media", host="localhost"), timeout=5) + with self.assertRaisesRegex(ValueError, "not allowed"): + download_remote_media("http://169.254.169.254/latest/meta-data", timeout=5) + + def test_redirect_destination_is_checked_before_fetch(self): + configure_media_url_security(["127.0.0.1"], max_file_size_mb=64) + with self.assertRaisesRegex(ValueError, "not allowed"): + download_remote_media(self._url("/other-host-redirect"), timeout=5) + self.assertFalse(self.server.redirect_target_reached) + + def test_same_domain_redirect_is_allowed(self): + configure_media_url_security(["127.0.0.1"], max_file_size_mb=64) + self.assertEqual( + download_remote_media(self._url("/same-host-redirect"), timeout=5), + b"remote-media", + ) + + def test_redirect_count_is_bounded(self): + configure_media_url_security(["127.0.0.1"], max_file_size_mb=64) + with self.assertRaises(requests.exceptions.TooManyRedirects): + download_remote_media(self._url("/redirect-loop"), timeout=5) + + def test_declared_oversized_response_is_rejected(self): + configure_media_url_security(["127.0.0.1"], max_file_size_mb=1) + with self.assertRaisesRegex(ValueError, "download limit"): + download_remote_media(self._url("/oversized"), timeout=5) + + def test_streamed_oversized_response_is_rejected(self): + configure_media_url_security(["127.0.0.1"], max_file_size_mb=1) + with self.assertRaisesRegex(ValueError, "download limit"): + download_remote_media(self._url("/chunked-oversized"), timeout=5) + + def test_invalid_allowlist_entries_are_rejected(self): + for domain in ( + "https://media.example.com", + "media.example.com/path", + "media.example.com:443", + "", + ): + with self.subTest(domain=domain): + with self.assertRaises(ValueError): + configure_media_url_security([domain], max_file_size_mb=64) + + def test_backslash_userinfo_parser_confusion_cannot_bypass_allowlist(self): + configure_media_url_security(["safe.example.org"], max_file_size_mb=64) + with self.assertRaisesRegex(ValueError, "not allowed"): + download_remote_media( + r"https://evil.example\@safe.example.org/media", timeout=5 + ) + + def test_all_common_loaders_share_the_policy(self): + blocked = ValueError("media URL domain is not allowed") + with patch( + "sglang.srt.utils.common.download_remote_media", side_effect=blocked + ) as download: + for loader in ( + get_image_bytes, + _normalize_video_input, + load_audio, + ): + with self.subTest(loader=loader.__name__): + with self.assertRaisesRegex(ValueError, "not allowed"): + loader("https://blocked.example/media") + self.assertEqual(download.call_count, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/test_server_args_migration.py b/test/registered/unit/test_server_args_migration.py index d86b93dcc..5ef88afde 100644 --- a/test/registered/unit/test_server_args_migration.py +++ b/test/registered/unit/test_server_args_migration.py @@ -8,6 +8,7 @@ import argparse import unittest from sglang.srt.server_args import ServerArgs +from sglang.srt.utils.common import configure_media_url_security from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.test_utils import CustomTestCase @@ -79,6 +80,33 @@ class TestServerArgsAnnotatedCli(CustomTestCase): self.assertEqual(sa.extra_metric_labels, {"k": "v"}) self.assertEqual(sa.forward_hooks, [{"type": "test"}]) + def test_media_url_security_args(self): + try: + sa = self._parse( + [ + "--allowed-media-domains", + "Media.Example.com.", + "127.0.0.1", + "--media-url-max-file-size-mb", + "32", + ] + ) + self.assertEqual( + sa.allowed_media_domains, ["127.0.0.1", "media.example.com"] + ) + self.assertEqual(sa.media_url_max_file_size_mb, 32) + finally: + configure_media_url_security([], max_file_size_mb=64) + + def test_media_url_security_args_reject_invalid_values(self): + try: + with self.assertRaises(ValueError): + self._parse(["--allowed-media-domains", "https://media.example.com"]) + with self.assertRaises(ValueError): + self._parse(["--media-url-max-file-size-mb", "-1"]) + finally: + configure_media_url_security([], max_file_size_mb=64) + def test_literal_auto_derives_choices(self): """Literal type annotations produce argparse choices automatically.""" sa = self._parse(