feat: add safeguards for remote media URLs (#34892)

This commit is contained in:
Mick
2026-08-15 18:12:15 +08:00
committed by GitHub
parent 0c072235f4
commit 35cefd1c51
14 changed files with 453 additions and 45 deletions
@@ -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 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 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). - 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). - 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`. - 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` - 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
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}><code>&#123;&#125;</code></td> <td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}><code>&#123;&#125;</code></td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Type: JSON / Dict</td> <td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Type: JSON / Dict</td>
</tr> </tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`--allowed-media-domains`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Restrict client-supplied HTTP(S) media URLs and redirect destinations to these exact hostnames.</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>Unrestricted</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Space-separated hostnames</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`--media-url-max-file-size-mb`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Maximum streamed size in MiB for one remote media download. Set to 0 to disable the limit.</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>`64`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Type: int</td>
</tr>
<tr> <tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`--mm-enable-dp-encoder`</td> <td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`--mm-enable-dp-encoder`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Enabling data parallelism for mm encoder. The dp size will be set to the tp size automatically.</td> <td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Enabling data parallelism for mm encoder. The dp size will be set to the tp size automatically.</td>
@@ -85,6 +85,7 @@ from sglang.srt.utils import (
CLIENT_MEDIA_EXCEPTIONS, CLIENT_MEDIA_EXCEPTIONS,
add_prometheus_middleware, add_prometheus_middleware,
configure_logger, configure_logger,
configure_media_url_security,
load_audio, load_audio,
load_image, load_image,
load_video, load_video,
@@ -308,6 +309,10 @@ class MMEncoder:
argument.""" argument."""
logger.info(f"init MMEncoder {rank}/{server_args.tp_size}") logger.info(f"init MMEncoder {rank}/{server_args.tp_size}")
self.server_args = server_args 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") publish(server_args, role="encoder")
self.rank = rank self.rank = rank
# DP rank for metric labels; overridden by run_dp_worker in DP mode. # DP rank for metric labels; overridden by run_dp_worker in DP mode.
@@ -32,6 +32,7 @@ from sglang.srt.multimodal.transport.cuda_ipc import (
) )
from sglang.srt.utils import ( from sglang.srt.utils import (
CLIENT_MEDIA_EXCEPTIONS, CLIENT_MEDIA_EXCEPTIONS,
configure_media_url_security,
envs, envs,
is_cpu, is_cpu,
is_npu, is_npu,
@@ -199,6 +200,10 @@ class BaseMultimodalProcessor(ABC):
self._processor = _processor self._processor = _processor
self.server_args = server_args self.server_args = server_args
self.transport_mode = transport_mode 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( configured_mm_feature_transport = getattr(
server_args, "mm_feature_transport", "cpu" server_args, "mm_feature_transport", "cpu"
) )
@@ -24,7 +24,6 @@ from __future__ import annotations
import base64 import base64
import logging import logging
import urllib.request
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any, Dict, List, Optional, Tuple, Type, Union 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 ( from sglang.srt.parser.inkling_tokenizer import (
INKLING_SPECIAL_TOKEN_IDS, INKLING_SPECIAL_TOKEN_IDS,
) )
from sglang.srt.utils.common import download_remote_media
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -82,8 +82,7 @@ def _resolve_media_item(item):
header, _, payload = url.partition(",") header, _, payload = url.partition(",")
return base64.b64decode(payload) if ";base64" in header else payload.encode() return base64.b64decode(payload) if ";base64" in header else payload.encode()
if url.startswith(("http://", "https://")): if url.startswith(("http://", "https://")):
with urllib.request.urlopen(url, timeout=30) as resp: return download_remote_media(url, timeout=30)
return resp.read()
return url # plain path / file:// -> handled by the per-modality byte loader return url # plain path / file:// -> handled by the per-modality byte loader
@@ -186,18 +186,14 @@ class MiMoAudioPipeline:
dl_start = time.perf_counter() dl_start = time.perf_counter()
timeout = int(os.getenv("REQUEST_TIMEOUT", "5")) timeout = int(os.getenv("REQUEST_TIMEOUT", "5"))
try: try:
with common.get_mm_http_session().get( content = common.download_remote_media(audio, timeout=timeout)
audio, stream=True, timeout=timeout
) as response:
response.raise_for_status()
dl_elapsed_ms = (time.perf_counter() - dl_start) * 1000 dl_elapsed_ms = (time.perf_counter() - dl_start) * 1000
if dl_elapsed_ms > 1000.0: if dl_elapsed_ms > 1000.0:
content_len = len(response.content)
logger.warning( logger.warning(
f"Slow audio download: {dl_elapsed_ms:.2f}ms, " f"Slow audio download: {dl_elapsed_ms:.2f}ms, "
f"size={content_len / 1024:.1f}KB, url={audio}" f"size={len(content) / 1024:.1f}KB, url={audio}"
) )
file = io.BytesIO(response.content) file = io.BytesIO(content)
except Exception as e: except Exception as e:
dl_elapsed_ms = (time.perf_counter() - dl_start) * 1000 dl_elapsed_ms = (time.perf_counter() - dl_start) * 1000
logger.error( logger.error(
@@ -5,6 +5,7 @@ import base64
import copy import copy
import json import json
import math import math
import os
import re import re
import subprocess import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -13,7 +14,6 @@ from io import BytesIO
from typing import List, Literal, Optional, Union from typing import List, Literal, Optional, Union
import numpy as np import numpy as np
import requests
import torch import torch
import torch.nn.functional as F import torch.nn.functional as F
from fastapi import HTTPException 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.multimodal.processors.qwen_vl import smart_nframes
from sglang.srt.utils import ImageData, VideoData from sglang.srt.utils import ImageData, VideoData
from sglang.srt.utils.common import download_remote_media
from sglang.utils import logger from sglang.utils import logger
@@ -485,12 +486,14 @@ class MiMoProcessor:
@staticmethod @staticmethod
def has_audio_track(path_or_data) -> bool: def has_audio_track(path_or_data) -> bool:
# In-process probe via torchcodec for bytes/path; ffprobe range # Never hand a client-supplied URL to ffprobe: its internal HTTP client
# request for HTTP URLs so we do not pre-download the blob here. # 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( if isinstance(path_or_data, str) and path_or_data.startswith(
("http://", "https://") ("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): if isinstance(path_or_data, bytes):
source = BytesIO(path_or_data) source = BytesIO(path_or_data)
@@ -1446,9 +1449,7 @@ class MiMoProcessor:
image_obj = image image_obj = image
elif isinstance(image, str): elif isinstance(image, str):
if image.startswith("http://") or image.startswith("https://"): if image.startswith("http://") or image.startswith("https://"):
with requests.get(image, stream=True) as response: with BytesIO(download_remote_media(image, timeout=3)) as bio:
response.raise_for_status()
with BytesIO(response.content) as bio:
image_obj = copy.deepcopy(Image.open(bio)) image_obj = copy.deepcopy(Image.open(bio))
elif image.startswith("file://"): elif image.startswith("file://"):
image_obj = Image.open(image[7:]) image_obj = Image.open(image[7:])
@@ -6,7 +6,6 @@ from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
import pybase64 import pybase64
import requests
import torch import torch
from sglang.srt.managers.schedule_batch import ( 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 ( from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens, MultimodalSpecialTokens,
) )
from sglang.srt.utils.common import download_remote_media
class MossVLImageProcessor(SGLangBaseProcessor): class MossVLImageProcessor(SGLangBaseProcessor):
@@ -426,13 +426,10 @@ class MossVLImageProcessor(SGLangBaseProcessor):
if value.startswith(("http://", "https://")): if value.startswith(("http://", "https://")):
timeout = int(os.getenv("REQUEST_TIMEOUT", "10")) timeout = int(os.getenv("REQUEST_TIMEOUT", "10"))
response = requests.get(value, stream=True, timeout=timeout) content = download_remote_media(value, timeout=timeout)
response.raise_for_status()
suffix = os.path.splitext(urlparse(value).path)[1] or ".mp4" suffix = os.path.splitext(urlparse(value).path)[1] or ".mp4"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f: with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
for chunk in response.iter_content(chunk_size=8192): f.write(content)
if chunk:
f.write(chunk)
return f.name, f.name return f.name, f.name
if value.startswith("data:"): if value.startswith("data:"):
+22
View File
@@ -70,6 +70,7 @@ from sglang.srt.speculative.decoupled_spec_io import DecoupledSpecIpcConfig
from sglang.srt.utils.common import ( from sglang.srt.utils.common import (
LORA_TARGET_ALL_MODULES, LORA_TARGET_ALL_MODULES,
SUPPORTED_LORA_TARGET_MODULES, SUPPORTED_LORA_TARGET_MODULES,
configure_media_url_security,
get_device, get_device,
get_device_memory_capacity, get_device_memory_capacity,
get_device_sm, get_device_sm,
@@ -2764,6 +2765,19 @@ class ServerArgs:
"environment override when this argument is 0.", "environment override when this argument is 0.",
NS("mm"), NS("mm"),
] = 0 ] = 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[ mm_preprocess_cache_size_mb: A[
Optional[int], Optional[int],
"CPU memory budget for content-addressed multimodal preprocessing " "CPU memory budget for content-addressed multimodal preprocessing "
@@ -3561,6 +3575,7 @@ class ServerArgs:
self._handle_moe_runner_backend_alias() self._handle_moe_runner_backend_alias()
self._handle_return_hidden_states_mode() self._handle_return_hidden_states_mode()
self._handle_media_url_security()
if self.model_path.lower() in ["none", "dummy"]: if self.model_path.lower() in ["none", "dummy"]:
return return
@@ -4051,6 +4066,13 @@ class ServerArgs:
f"but got {type(self.mm_process_config[key])}" 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): def _handle_deprecated_args(self):
if self.disable_fast_image_processor: if self.disable_fast_image_processor:
if self.image_processor_backend not in {"auto", "pil"}: if self.image_processor_backend not in {"auto", "pil"}:
+157 -16
View File
@@ -25,6 +25,7 @@ import gc
import importlib import importlib
import inspect import inspect
import io import io
import ipaddress
import itertools import itertools
import json import json
import logging import logging
@@ -75,7 +76,7 @@ from typing import (
) )
from unittest import SkipTest from unittest import SkipTest
from unittest.case import _ShouldStop from unittest.case import _ShouldStop
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urljoin, urlparse
import numpy as np import numpy as np
import orjson import orjson
@@ -1510,6 +1511,158 @@ def set_random_seed(seed: int) -> None:
_mm_http_session = threading.local() _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: def get_mm_http_session() -> requests.Session:
"""Per-thread HTTP session for multimodal downloads, to pool/reuse TCP """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://") audio_file.startswith("http://") or audio_file.startswith("https://")
): ):
timeout = int(os.getenv("REQUEST_TIMEOUT", "5")) timeout = int(os.getenv("REQUEST_TIMEOUT", "5"))
with get_mm_http_session().get(audio_file, timeout=timeout) as response: source = download_remote_media(audio_file, timeout=timeout)
response.raise_for_status()
source = response.content
elif isinstance(audio_file, str) and audio_file.startswith("file://"): elif isinstance(audio_file, str) and audio_file.startswith("file://"):
source = unquote(urlparse(audio_file).path) source = unquote(urlparse(audio_file).path)
elif isinstance(audio_file, str): elif isinstance(audio_file, str):
@@ -1753,13 +1904,7 @@ def get_image_bytes(image_file: Union[str, bytes]) -> bytes:
return image_file return image_file
if image_file.startswith(("http://", "https://")): if image_file.startswith(("http://", "https://")):
timeout = int(os.getenv("REQUEST_TIMEOUT", "3")) timeout = int(os.getenv("REQUEST_TIMEOUT", "3"))
response = get_mm_http_session().get(image_file, timeout=timeout) return download_remote_media(image_file, timeout=timeout)
try:
response.raise_for_status()
result = response.content
finally:
response.close()
return result
if image_file.startswith(("file://", "/")): if image_file.startswith(("file://", "/")):
with open(image_file, "rb") as f: with open(image_file, "rb") as f:
return f.read() return f.read()
@@ -1785,11 +1930,7 @@ def _normalize_video_input(
elif isinstance(video_file, str): elif isinstance(video_file, str):
if video_file.startswith(("http://", "https://")): if video_file.startswith(("http://", "https://")):
timeout = int(os.getenv("REQUEST_TIMEOUT", "10")) timeout = int(os.getenv("REQUEST_TIMEOUT", "10"))
with get_mm_http_session().get( return download_remote_media(video_file, timeout=timeout)
video_file, stream=True, timeout=timeout
) as response:
response.raise_for_status()
return response.content
elif video_file.startswith("data:"): elif video_file.startswith("data:"):
_, encoded = video_file.split(",", 1) _, encoded = video_file.split(",", 1)
return pybase64.b64decode(encoded, validate=True) return pybase64.b64decode(encoded, validate=True)
@@ -87,6 +87,8 @@ class TestBaseProcessorConfigExtraction(CustomTestCase):
server_args.mm_preprocess_cache_size_mb = None server_args.mm_preprocess_cache_size_mb = None
server_args.tokenizer_worker_num = 1 server_args.tokenizer_worker_num = 1
server_args.trust_mm_content_hashes = False server_args.trust_mm_content_hashes = False
server_args.allowed_media_domains = []
server_args.media_url_max_file_size_mb = 64
hf_config = MagicMock() hf_config = MagicMock()
mock_hf_processor = MagicMock() mock_hf_processor = MagicMock()
@@ -178,6 +180,8 @@ class TestMultimodalFeatureTransportRuntime(CustomTestCase):
tokenizer_worker_num=1, tokenizer_worker_num=1,
base_gpu_id=2, base_gpu_id=2,
tp_size=8, tp_size=8,
allowed_media_domains=[],
media_url_max_file_size_mb=64,
) )
@staticmethod @staticmethod
@@ -773,6 +777,8 @@ class TestDoubleBosGuard(CustomTestCase):
server_args.mm_preprocess_cache_size_mb = None server_args.mm_preprocess_cache_size_mb = None
server_args.tokenizer_worker_num = 1 server_args.tokenizer_worker_num = 1
server_args.trust_mm_content_hashes = False 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 = MagicMock()
mock_hf_processor.__class__.__name__ = "TestProcessor" mock_hf_processor.__class__.__name__ = "TestProcessor"
@@ -599,6 +599,8 @@ def test_kimi_processor_workers_clone_the_gpu_wrapper(processor_cls, wrapper_cls
mm_processor_worker_num=0, mm_processor_worker_num=0,
tokenizer_worker_num=1, tokenizer_worker_num=1,
base_gpu_id=0, base_gpu_id=0,
allowed_media_domains=[],
media_url_max_file_size_mb=64,
) )
processor = processor_cls( processor = processor_cls(
hf_config=SimpleNamespace(media_placeholder_token_id=42), hf_config=SimpleNamespace(media_placeholder_token_id=42),
@@ -80,6 +80,8 @@ def make_processor(config, image_processor_cls=None):
mm_processor_worker_num=1, mm_processor_worker_num=1,
tokenizer_worker_num=1, tokenizer_worker_num=1,
base_gpu_id=0, base_gpu_id=0,
allowed_media_domains=[],
media_url_max_file_size_mb=64,
) )
return QwenVLImageProcessor( return QwenVLImageProcessor(
hf_config, server_args, processor, None, skip_mm_pool=True hf_config, server_args, processor, None, skip_mm_pool=True
@@ -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()
@@ -8,6 +8,7 @@ import argparse
import unittest import unittest
from sglang.srt.server_args import ServerArgs 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.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase 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.extra_metric_labels, {"k": "v"})
self.assertEqual(sa.forward_hooks, [{"type": "test"}]) 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): def test_literal_auto_derives_choices(self):
"""Literal type annotations produce argparse choices automatically.""" """Literal type annotations produce argparse choices automatically."""
sa = self._parse( sa = self._parse(