"""URL 安全校验工具 — SSRF 防护. 统一的外部 URL 安全校验方案,覆盖所有渲染管线中的外部下载场景: - BGM 音频 URL 下载 - TTS 音频结果 URL 下载 - PiP 画中画素材 URL 下载 - 贴纸/封面等图片 URL 下载 - 通用 URL 可访问性校验 防护要点: 1. Scheme 白名单:仅允许 http/https 2. 主机 SSRF 防护:禁止内网 IP、回环地址、链路本地地址、元数据服务 3. 端口白名单:仅允许 80/443(标准 HTTP/HTTPS) 4. 域名校验:禁止 IP 直接访问(除非在白名单中) 5. 重定向防护:下载时禁止跳转到内网 """ from __future__ import annotations import ipaddress import logging import socket from urllib.parse import urlparse logger = logging.getLogger(__name__) # 允许的 URL scheme ALLOWED_SCHEMES = {"http", "https"} # 允许的端口(标准 HTTP/HTTPS) ALLOWED_PORTS = {80, 443} # 可信域名白名单(可根据实际 OSS/CDN 域名配置) # 从环境变量读取,格式:"oss-cn-hangzhou.aliyuncs.com,cdn.example.com" # 默认空表示所有公网域名都允许,但仍会做 SSRF 检查 import os TRUSTED_DOMAINS: set[str] = set() _env_trusted = os.environ.get("URL_SECURITY_TRUSTED_DOMAINS", "") if _env_trusted: TRUSTED_DOMAINS = {d.strip() for d in _env_trusted.split(",") if d.strip()} # 是否允许 IP 直接访问(默认禁止,防止绕过 DNS 校验) ALLOW_DIRECT_IP = os.environ.get("URL_SECURITY_ALLOW_DIRECT_IP", "false").lower() == "true" # 最大 URL 长度 MAX_URL_LENGTH = 2048 class UrlSecurityError(ValueError): """URL 安全校验失败.""" pass def validate_url_safety(url: str, *, purpose: str = "download") -> str: """校验 URL 安全性,返回标准化后的 URL(供下游使用). Args: url: 待校验的 URL purpose: 用途描述(用于日志),如 "bgm_download"、"tts_download" Returns: 标准化后的 URL Raises: UrlSecurityError: URL 不安全 """ if not url: raise UrlSecurityError("URL 为空") if len(url) > MAX_URL_LENGTH: raise UrlSecurityError(f"URL 过长 ({len(url)} > {MAX_URL_LENGTH})") # 解析 URL try: parsed = urlparse(url) except Exception as e: raise UrlSecurityError(f"URL 解析失败: {e}") from e # 1. Scheme 校验 if not parsed.scheme or parsed.scheme.lower() not in ALLOWED_SCHEMES: raise UrlSecurityError(f"不允许的 URL scheme: {parsed.scheme}") # 2. 主机名校验 hostname = parsed.hostname if not hostname: raise UrlSecurityError("URL 缺少主机名") # 2.1 常见内网主机名前置拦截(防止 DNS rebinding 绕过) _check_internal_hostnames(hostname) # 3. 端口校验 port = parsed.port if port is not None and port not in ALLOWED_PORTS: raise UrlSecurityError(f"不允许的端口: {port}") # 4. SSRF 防护 - 解析 IP 并检查 try: # 先判断是否是 IP 地址 ip_obj = None try: ip_obj = ipaddress.ip_address(hostname) except ValueError: pass # 不是 IP,继续走域名解析 if ip_obj is not None: # 是直接 IP 访问 if not ALLOW_DIRECT_IP and not _is_trusted_ip(ip_obj): raise UrlSecurityError(f"禁止直接 IP 访问: {hostname}") _check_ssrf_ip(ip_obj) else: # 域名 — 解析 DNS 检查 SSRF _check_ssrf_domain(hostname) except UrlSecurityError: raise except Exception as e: logger.warning("URL 安全校验异常: url=%s purpose=%s error=%s", url[:80], purpose, e) raise UrlSecurityError(f"URL 安全校验异常: {e}") from e # 5. 可信域名校验(如果配置了白名单) if TRUSTED_DOMAINS and not _is_trusted_domain(hostname): raise UrlSecurityError(f"域名不在可信白名单中: {hostname}") logger.debug("URL 安全校验通过: url=%s purpose=%s", url[:80], purpose) return url def _check_internal_hostnames(hostname: str) -> None: """前置检查常见内网/敏感主机名,防止 DNS 解析层绕过.""" hostname_lower = hostname.lower() internal_hostnames = { "localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback", "metadata", "metadata.google.internal", "169.254.169.254", # 云元数据服务 } if hostname_lower in internal_hostnames: raise UrlSecurityError(f"禁止访问内部主机名: {hostname}") # 检查以 .local / .internal 结尾的主机名 if hostname_lower.endswith((".local", ".internal", ".localdomain")): raise UrlSecurityError(f"禁止访问内网域名: {hostname}") def _check_ssrf_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None: """检查 IP 是否属于 SSRF 风险范围.""" # 回环地址 if ip_obj.is_loopback: raise UrlSecurityError(f"禁止访问回环地址: {ip_obj}") # 私有地址(内网) if ip_obj.is_private: raise UrlSecurityError(f"禁止访问内网地址: {ip_obj}") # 链路本地地址 if ip_obj.is_link_local: raise UrlSecurityError(f"禁止访问链路本地地址: {ip_obj}") # 组播地址 if ip_obj.is_multicast: raise UrlSecurityError(f"禁止访问组播地址: {ip_obj}") # 未指定地址(0.0.0.0 / ::) if ip_obj.is_unspecified: raise UrlSecurityError(f"禁止访问未指定地址: {ip_obj}") # 保留地址 if ip_obj.is_reserved: raise UrlSecurityError(f"禁止访问保留地址: {ip_obj}") def _check_ssrf_domain(hostname: str) -> None: """对域名做 DNS 解析并检查所有解析结果的 IP 是否安全. 注意:这不能完全防止 DNS rebinding,但能防御大部分 SSRF 场景。 """ try: # 解析所有地址 infos = socket.getaddrinfo(hostname, None) if not infos: raise UrlSecurityError(f"域名解析失败: {hostname}") for info in infos: ip_str = info[4][0] try: ip_obj = ipaddress.ip_address(ip_str) _check_ssrf_ip(ip_obj) except ValueError: # 无法解析为 IP,跳过(不应该发生) continue except socket.gaierror as e: raise UrlSecurityError(f"域名解析失败: {hostname} ({e})") from e def _is_trusted_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """检查 IP 是否在可信列表中(目前通过环境变量配置域名,IP 级信任暂不开放).""" return False def _is_trusted_domain(hostname: str) -> bool: """检查域名是否在可信白名单中(支持子域名匹配).""" hostname_lower = hostname.lower() if hostname_lower in TRUSTED_DOMAINS: return True # 检查子域名 for domain in TRUSTED_DOMAINS: if hostname_lower.endswith("." + domain.lower()): return True return False def is_url_safe(url: str, *, purpose: str = "download") -> bool: """便捷函数:检查 URL 是否安全,不抛异常.""" try: validate_url_safety(url, purpose=purpose) return True except UrlSecurityError: return False