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
@@ -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.
@@ -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"
)
@@ -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
@@ -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(
@@ -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"):
@@ -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:"):
+22
View File
@@ -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"}:
+157 -16
View File
@@ -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)