Prhub

#34892 feat: add safeguards for remote media URLs

原始 PR 作者 mickqian 合并时间 2026-08-15 18:12 文件变更 14 提交数 3 评论 7 代码增减 +453 / -45

执行摘要

新增远程媒体 URL 域名白名单与下载大小限制,防 SSRF

PR body 明确提出:Allow deployments to restrict client-supplied remote media URLs and bound download size,并强调 The domain allowlist remains opt-in for backward compatibility. Remote downloads default to a 64 MiB limit。SGLang 多模态入口接受客户端直接提供的 HTTP(S) 媒体 URL,由服务端进程下载后解码;此前既无域名限制也无大小上限,攻击者可以诱导服务端访问内网地址或云元数据端点(测试中明确覆盖了 http://169.254.169.254/latest/meta-data),构成 SSRF,也可以提交超大媒体耗尽内存与带宽。该 PR 以默认兼容的方式补齐这两条防线。

值得精读。核心看点:

1) download_remote_media 中先用 requests.Request(...).prepare().url 统一 URL 规范化再校验,避免解析器分歧绕过;
2) 手动逐跳重定向校验 + 流式字节上限 + 全局 deadline 的三重防护设计;
3) 在 dummy-model 边界之前通过 server args 发布进程级策略的接线方式。同时注意仓库正处于 server_args → config bag 迁移期,本 PR 在 base_processor/encode_server 新增的直读点应在后续 step 迁移中纳入 bags。

讨论亮点

本 PR 的 review_comments_count 为 0,PR 评论区没有实质设计争论,主要由作者发起的 3 轮 /rerun-test 指令与机器人执行结果构成(覆盖 test_media_url_security.pytest_server_args_migration.pytest_base_processor_bad_input.pytest_mm_process_config.pytest_kimi_k25.py、rust/qwen e2e parity 等),最终全绿后无异议合并。3 次 commit 的演进顺序(功能实现 → 直接读取 server args → 更新测试 fixtures)说明作者是先完成核心逻辑,再简化参数访问方式,最后补齐测试配套。

实现拆解

  1. 新增安全下载器核心(python/sglang/srt/utils/common.py):新增 _normalize_media_domain(支持 IP 归一化、IDNA 域名编码、去尾点,拒绝端口/路径/URL 片段)、configure_media_url_security(进程级策略发布,返回规范化域名列表)、_assert_media_url_allowed(域名白名单校验)、download_remote_media(手动跟随重定向、逐跳校验目的地、流式读取并强制字节上限与全局 deadline)。
  2. 新增 server 参数与启动接线(python/sglang/srt/server_args.py):新增 allowed_media_domains(List[str],默认空 = 不限制)与 media_url_max_file_size_mb(int,默认 64,0 表示禁用);__post_init__ 在 dummy-model 边界之前调用 _handle_media_url_security 完成规范化并发布策略,确保所有 worker 线程共享同一配置。
  3. 统一 loader 接入:公共加载器 load_audioget_image_bytes_normalize_video_input 从直连 get_mm_http_session() 切换到 download_remote_media;模型特定处理器 mimo_audio.preprocess_audio(保留慢速下载告警)、mimo_v2.has_audio_track(放弃 ffprobe HTTP Range 探测,避免绕过策略)、mimo_v2.fetch_imagemoss_vl._normalize_video_string(从流式写文件改为整块写入临时文件)、inkling._resolve_media_item(urllib 替换为共享下载器)全部接入。
  4. 多进程/分离部署发布base_processor.BaseMultimodalProcessor.__init__disaggregation/encode_server.py 的 MMEncoder 初始化时调用 configure_media_url_security,保证 DP/独立编码进程也遵守同一策略。
  5. 测试与文档配套:新增 test_media_url_security.py(本地 ThreadingHTTPServer 模拟同域/跨域重定向、声明长度与 chunked 超限、重定向循环、非法白名单条目、backslash userinfo 绕过、三个加载器共享策略);扩展 test_server_args_migration.py(参数归一化如 Media.Example.com.media.example.com、拒绝非法值并重置全局状态);test_mm_process_config.pytest_kimi_k25.py、rust/qwen _fixtures.py 补充策略重置;docs 记录新参数语义。
文件 模块 状态 重要度
python/sglang/srt/utils/common.py 工具层 modified 8.72
test/registered/unit/multimodal/test_media_url_security.py 安全测试 added 7.94
python/sglang/srt/server_args.py 参数配置 modified 6.68
test/registered/unit/test_server_args_migration.py 参数测试 modified 6.24
python/sglang/srt/multimodal/processors/mimo_audio.py 音频处理 modified 6.14
python/sglang/srt/multimodal/processors/mimo_v2.py 多模态处理 modified 6.08
python/sglang/srt/multimodal/processors/base_processor.py 处理器基类 modified 4.96
python/sglang/srt/disaggregation/encode_server.py 编码服务 modified 4.96
docs/docs/advanced_features/server_arguments.mdx 参数文档 modified 3.23

关键符号

download_remote_media configure_media_url_security _normalize_media_domain _assert_media_url_allowed _handle_media_url_security

关键源码片段

python/sglang/srt/utils/common.py core-logic

安全策略核心实现:新增域名白名单、重定向校验、流式下载大小限制的统一下载器,所有媒体加载路径的最终汇聚点。

def download_remote_media(url: str, timeout: float) -> bytes:
    """按统一媒体 URL 策略下载一个 HTTP(S) 媒体对象。    重定向被手动跟随,确保每个目标地址在建立连接前都先通过白名单校验;
    响应体以流式读取,在保证总请求截止时间的同时强制执行字节数上限,
    避免先将攻击者可控的响应体整体缓冲进内存。
    """
    if timeout <= 0:
        raise ValueError("media URL timeout must be positive")
​
    session = get_mm_http_session() # 复用 per-thread 连接池
    deadline = time.monotonic() + timeout
    current_url = url
​
    for redirect_count in range(_MAX_MEDIA_URL_REDIRECTS + 1):
        # 用 requests 的 prepare 逻辑做与 urllib3 一致的 URL 规范化后再校验,
        # 避免反斜杠、userinfo 分隔符等解析器不一致导致的绕过。
        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")
test/registered/unit/multimodal/test_media_url_security.py test-coverage

新增的安全回归测试文件,用本地 HTTP 服务器覆盖白名单、重定向校验、大小限制、绕过攻击等全部核心场景,是验证安全策略的关键配套。

class _MediaHandler(http.server.BaseHTTPRequestHandler):
    """本地测试用 HTTP 服务,模拟可被安全策略触发的各种响应。"""
​
    def do_GET(self):
        if self.path == "/other-host-redirect":
            # 302 跳到另一个 host:应被域名白名单拦截,且目标不能被访问
            self.send_response(302)
            self.send_header(
                "Location",
                f"http://localhost:{self.server.server_port}/redirect-target",
            )
            self.end_headers()
            return
​
        if self.path == "/chunked-oversized":
            # 不声明 Content-Length,靠流式读取触发大小上限
            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 # 静默访问日志,避免 CI 输出噪音
​
​
class TestMediaURLSecurity(unittest.TestCase):
    def test_redirect_destination_is_checked_before_fetch(self):
        # 白名单只放行 127.0.0.1,跨域重定向应在校验阶段就被拒绝
        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_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)
python/sglang/srt/server_args.py core-logic

新增两个安全相关 server 参数并在启动早期发布策略,是部署方启用 / 配置安全能力的入口。

    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
​
    def _handle_media_url_security(self):
        """Normalize and publish the media URL policy before workers start."""
        # 在 dummy-model 边界之前发布,让所有 worker 共享同一策略;
        # 默认 64 MiB,0 表示禁用大小限制。
        self.allowed_media_domains = configure_media_url_security(
            self.allowed_media_domains,
            self.media_url_max_file_size_mb,
        )

评论区精华

CI 回归验证 other

作者先后发起 3 轮 /rerun-test,覆盖 test_media_url_security.py、test_server_args_migration.py、test_base_processor_bad_input.py、test_mm_process_config.py、test_kimi_k25.py、rust/qwen e2e parity 等,重点验证新增安全测试与既有多模态测试的兼容性。

结论:三轮 rerun 全部通过,PR 无 review 评论即合并;说明变更未破坏既有多模态链路。 · 已解决

风险与影响

  1. DNS 重绑定(TOCTOU)未被覆盖_assert_media_url_allowed 校验的是主机名字符串而非解析后的 IP,域名解析与连接建立之间若 DNS 变化仍可绕过;窗口极小且测试未覆盖,属于已知盲区。
  2. 默认行为变化media_url_max_file_size_mb 默认 64 MiB,此前远程下载无大小限制,超大媒体(如 > 64 MiB 视频)的部署会开始失败,属于静默行为变更;mimo_v2.has_audio_track 从 ffprobe Range 探测改为整体下载,任何超过限制的视频 URL 的音频探测都会被拒绝。
  3. 进程级全局状态configure_media_url_security 修改模块级全局变量,ServerArgs.__post_init__base_processor.pyencode_server.py 多处调用;同一进程内多 engine 配置并存时会互相覆盖,与 #34376 修复的 per-runner 配置污染模式类似,测试均通过 try/finally 重置全局状态来缓解。
  4. 下载路径回归面moss_vl.py 从流式写文件改为整块内容写临时文件,inkling.pyurllib.request 改为 requests 下载(UA、连接复用等行为有细微差异);受 64 MiB 上限约束,内存风险可控,但涉及 4 个模型特定处理器的行为变化。
  5. 未开启白名单时的防护缺口:白名单默认关闭时,SSRF 防护实际不生效,只剩大小限制;文档需明确提醒部署方主动开启。

对部署方:新增 --allowed-media-domains--media-url-max-file-size-mb 两个可选参数,白名单 opt-in、默认兼容旧行为,但 64 MiB 默认上限会约束此前无限制的超大媒体下载。对系统:公共 loader 与 4 个模型特定 processor、encode server、base processor 统一走安全下载器,消除了 requests/urllib/ffprobe 多套 HTTP 路径,安全策略可集中演进。对团队:建立了可复用的媒体 URL 安全基线与本地 HTTP 测试基础设施(可扩展模拟更多攻击场景);影响范围覆盖所有接收远程多媒体 URL 的多模态服务部署,主要在数据处理链路而非推理路径。

默认 opt-in 白名单 全局配置状态 下载路径行为变更 DNS 重绑定未覆盖 64 MiB 默认限制

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论