9e10e31a29
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 23s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 15m19s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 15m19s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
P0-1 SSRF防护:统一URL安全校验方案 - 新增 url_security.py:SSRF防护工具(scheme白名单、端口白名单、内网IP拦截、内网主机名拦截、DNS解析校验) - generation.py URL校验函数增加SSRF前置校验 - PiP引擎URL类型增加安全校验 - 贴纸引擎URL类型增加安全校验 P0-2 路径遍历防护:输入路径校验强化 - 新增 path_security.py:路径安全工具(路径遍历防护、local:// schema校验、文件名清理、允许目录白名单) - resolve_asset_path 加固:本地绝对路径白名单、空字节检测、存储键路径遍历拦截 - PiP引擎local_path类型:增加允许目录校验 - 贴纸引擎:区分本地路径/URL,分别增加安全校验 P1 裸subprocess统一:3处FFmpeg调用走run_ffmpeg - unified_render_service.py _extract_audio:改用 run_ffmpeg - compose_video.py:改用 run_ffmpeg - edit_plan_generation.py:改用 run_ffmpeg 新增测试: - test_url_security.py:29个SSRF防护单测 - test_path_security.py:27个路径遍历防护单测 - 原有171+105=276个单测零回归
223 lines
7.2 KiB
Python
Executable File
223 lines
7.2 KiB
Python
Executable File
"""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
|