feat: add safeguards for remote media URLs (#34892)
This commit is contained in:
@@ -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:"):
|
||||
|
||||
@@ -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"}:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user