From cf7e295f35989455fdc885ca1ae75bdca195c645 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 29 Aug 2026 00:49:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20SSRF=20=E5=86=85=E7=BD=91=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E6=94=B9=E7=94=A8=20ipaddress=20=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=BA=93=EF=BC=8C=E8=A6=86=E7=9B=96=E5=85=A8=E9=83=A8=20IPv4/I?= =?UTF-8?q?Pv6=20=E7=A7=81=E6=9C=89=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI Code Review 第三轮指出:IPv6 链路本地地址使用字符串前缀匹配 (fe80/fe90/...)不严谨且易漏。 修复:新增 _is_private_or_reserved_host(),统一用标准库 ipaddress.ip_address().is_private/is_loopback/is_link_local/ is_reserved 判定,一次性准确覆盖: - IPv4: 10/8、172.16/12、192.168/16、127/8、169.254/16、0.0.0.0 - IPv6: ::1、fc00::/7(ULA)、fe80::/10(链路本地)、保留段 删除手工字符串切片逻辑,可读性和正确性都更好。 36 个测试全部通过。 --- apps/api/app/api/routes/generation_cover.py | 38 +++++++++++---------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index fe6f0adf5..45c5d27fb 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -8,6 +8,7 @@ from __future__ import annotations +import ipaddress import logging import re from typing import Any, List, Optional @@ -191,6 +192,23 @@ def _endpoint_host(value: str) -> str: return (urlparse("//" + v).hostname or "").lower() +def _is_private_or_reserved_host(host: str) -> bool: + """判断主机名是否为内网/回环/链路本地/保留地址(IPv4 与 IPv6 统一处理)。 + + 使用标准库 ipaddress 判定;非 IP 主机名(如 localhost)单独处理。 + """ + h = host.strip().lower() + if h in {"localhost", "0.0.0.0", "::", "::1"}: + return True + try: + addr = ipaddress.ip_address(h) + # is_private 覆盖 10/8、172.16/12、192.168/16、127/8、169.254/16、 + # ::1、fc00::/7、fe80::/10 等全部私有/保留段 + return bool(addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved) + except ValueError: + return False + + def _is_trusted_media_url(url: str) -> bool: """校验 URL 是否指向受信任的存储域名(OSS bucket / 本地存储),防止 SSRF。 @@ -206,25 +224,9 @@ def _is_trusted_media_url(url: str) -> bool: host = (parsed.hostname or "").lower() if not host: return False - # 显式拒绝内网/保留地址(IPv4 + IPv6) - if host in {"localhost", "0.0.0.0", "::", "::1"}: + # 拒绝一切内网/回环/链路本地/保留地址(IPv4 + IPv6,标准库判定) + if _is_private_or_reserved_host(host): return False - if host.startswith(("127.", "10.", "192.168.", "169.254.")): - return False - # IPv6 本地/链路本地/唯一本地地址:[::1] / fe80:: / fc00::/7 - if ":" in host and ( - host == "::1" - or host.startswith(("fe80", "fe90", "fea0", "feb0", "fec0", "fed0", "fee0", "fef0")) - or host.startswith(("fc", "fd")) - ): - return False - # 172.16.0.0/12 - try: - parts = [int(p) for p in host.split(".")] - if len(parts) == 4 and parts[0] == 172 and 16 <= parts[1] <= 31: - return False - except ValueError: - pass # 允许:自家 OSS bucket 域名(.)或 endpoint 自身及其子域 try: storage_svc = get_shared_storage_service()