Files
xiaoxia-saas/packages/domain/url_security.py
T
AI Bot 019201fb5c
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 18s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 58s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m27s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 16s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m27s
AI Code Review / AI Code Review (pull_request) Successful in 2m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m14s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m32s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m7s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1221h16m7s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1221h17m38s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1221h17m42s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1221h17m44s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1223h6m35s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1223h6m37s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1223h6m37s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1221h50m26s
test(wave128): url_security纯逻辑抽离+110单测
- 新建 packages/domain/url_security.py:SSRF防护纯逻辑模块
  - URL基础校验(scheme/port/hostname/internal/IP-SSRF/trusted-domain)
  - 魔数校验纯函数(接收bytes)
  - 常量、异常类、辅助函数
- packages/shared/url_security.py 改为薄包装
  - 保留DNS解析、文件下载等有副作用逻辑
  - 纯逻辑委托给domain层,完全向后兼容
- 110个单测全覆盖:常量/内部主机名/可信域名/SSRF IP/IP判断/URL校验/魔数校验
- 修复check_ssrf_ip检查顺序:link_local/unspecified放private前
2026-07-27 13:33:25 +08:00

339 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""URL 安全校验纯逻辑 — SSRF 防护.
纯函数模块,无网络/文件 IO,无环境变量依赖,所有配置通过参数传入。
供 packages/shared/url_security.py 作为薄包装调用,也可直接用于单测。
防护要点(纯逻辑部分):
1. Scheme 白名单校验
2. 内部主机名拦截(字符串匹配)
3. 端口白名单校验
4. IP 格式 SSRF 检查(回环/私有/链路本地/组播/未指定/保留)
5. 可信域名匹配(支持子域名)
6. 文件头魔数校验(接收 bytes)
"""
from __future__ import annotations
import ipaddress
from urllib.parse import urlparse
class UrlSecurityError(ValueError):
"""URL 安全校验失败."""
pass
# ── 常量 ────────────────────────────────────────────────────────────────────
ALLOWED_SCHEMES = frozenset({"http", "https"})
ALLOWED_PORTS = frozenset({80, 443})
MAX_URL_LENGTH = 2048
# 已知内部/敏感主机名集合
INTERNAL_HOSTNAMES = frozenset(
{
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata",
"metadata.google.internal",
"169.254.169.254",
}
)
# 内网域名后缀
INTERNAL_DOMAIN_SUFFIXES = (".local", ".internal", ".localdomain")
# 文件魔数表 — key: MIME, value: list of 签名组,每组内所有 (offset, bytes) 都匹配才算命中
MAGIC_NUMBERS: dict[str, list[list[tuple[int, bytes]]]] = {
# 音频
"audio/mpeg": [
[(0, b"ID3")],
[(0, b"\xff\xfb")],
[(0, b"\xff\xf3")],
[(0, b"\xff\xf2")],
[(0, b"\xff\xfa")],
[(0, b"\xff\xf9")],
],
"audio/wav": [[(0, b"RIFF"), (8, b"WAVE")]],
"audio/x-wav": [[(0, b"RIFF"), (8, b"WAVE")]],
"audio/ogg": [[(0, b"OggS")]],
"application/ogg": [[(0, b"OggS")]],
"audio/flac": [[(0, b"fLaC")]],
"audio/aac": [[(0, b"\xff\xf1")], [(0, b"\xff\xf9")]],
"audio/aacp": [[(0, b"\xff\xf1")], [(0, b"\xff\xf9")]],
"audio/mp4": [[(4, b"ftyp")]],
"audio/x-m4a": [[(4, b"ftyp")]],
# 视频
"video/mp4": [[(4, b"ftyp")]],
"video/quicktime": [[(4, b"ftyp")]],
"video/x-matroska": [[(0, b"\x1a\x45\xdf\xa3")]],
"video/webm": [[(0, b"\x1a\x45\xdf\xa3")]],
"video/x-msvideo": [[(0, b"RIFF"), (8, b"AVI ")]],
# 图片
"image/jpeg": [[(0, b"\xff\xd8\xff")]],
"image/png": [[(0, b"\x89PNG\r\n\x1a\n")]],
"image/gif": [[(0, b"GIF87a")], [(0, b"GIF89a")]],
"image/webp": [[(0, b"RIFF"), (8, b"WEBP")]],
"image/bmp": [[(0, b"BM")]],
}
ALLOWED_AUDIO_MIME_TYPES = frozenset(
{
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav",
"audio/pcm",
"audio/ogg",
"audio/opus",
"audio/flac",
"audio/aac",
"audio/m4a",
"audio/x-m4a",
"audio/mp4",
"application/octet-stream",
}
)
ALLOWED_VIDEO_MIME_TYPES = frozenset(
{
"video/mp4",
"video/quicktime",
"video/x-matroska",
"video/webm",
"video/avi",
"video/x-msvideo",
"video/mpeg",
"application/octet-stream",
}
)
ALLOWED_IMAGE_MIME_TYPES = frozenset(
{
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
}
)
# ── 主机名 / 域名校验 ──────────────────────────────────────────────────────
def check_internal_hostname(hostname: str) -> None:
"""检查主机名是否为内部/敏感主机名,是则抛出 UrlSecurityError.
检查内容:
- 精确匹配 INTERNAL_HOSTNAMES 集合
- 后缀匹配 INTERNAL_DOMAIN_SUFFIXES(.local/.internal/.localdomain)
"""
hostname_lower = hostname.lower()
if hostname_lower in INTERNAL_HOSTNAMES:
raise UrlSecurityError(f"禁止访问内部主机名: {hostname}")
if hostname_lower.endswith(INTERNAL_DOMAIN_SUFFIXES):
raise UrlSecurityError(f"禁止访问内网域名: {hostname}")
def is_trusted_domain(hostname: str, trusted_domains: set[str]) -> bool:
"""检查域名是否在可信白名单中(支持子域名匹配).
匹配规则:
- 精确匹配
- 子域名匹配(hostname 以 .domain 结尾)
Args:
hostname: 待检查的主机名
trusted_domains: 可信域名集合,为空表示不限制
"""
if not trusted_domains:
return True
hostname_lower = hostname.lower()
if hostname_lower in {d.lower() for d in trusted_domains}:
return True
for domain in trusted_domains:
if hostname_lower.endswith("." + domain.lower()):
return True
return False
# ── IP SSRF 检查 ───────────────────────────────────────────────────────────
def check_ssrf_ip(ip_str: str) -> None:
"""检查 IP 地址是否存在 SSRF 风险,有风险则抛出 UrlSecurityError.
检查项:回环、私有、链路本地、组播、未指定、保留地址。
Args:
ip_str: IP 地址字符串(IPv4 或 IPv6)
Raises:
UrlSecurityError: IP 属于 SSRF 风险范围
ValueError: ip_str 不是合法 IP 地址(调用方应自行捕获处理)
"""
ip_obj = ipaddress.ip_address(ip_str)
if ip_obj.is_loopback:
raise UrlSecurityError(f"禁止访问回环地址: {ip_obj}")
if ip_obj.is_link_local:
raise UrlSecurityError(f"禁止访问链路本地地址: {ip_obj}")
if ip_obj.is_unspecified:
raise UrlSecurityError(f"禁止访问未指定地址: {ip_obj}")
if ip_obj.is_multicast:
raise UrlSecurityError(f"禁止访问组播地址: {ip_obj}")
if ip_obj.is_reserved:
raise UrlSecurityError(f"禁止访问保留地址: {ip_obj}")
if ip_obj.is_private:
raise UrlSecurityError(f"禁止访问内网地址: {ip_obj}")
def is_ip_address(hostname: str) -> bool:
"""判断主机名是否为 IP 地址格式(IPv4 或 IPv6)."""
try:
ipaddress.ip_address(hostname)
return True
except ValueError:
return False
# ── URL 基础校验 ───────────────────────────────────────────────────────────
def validate_url_basic(
url: str,
*,
trusted_domains: set[str] | None = None,
allow_direct_ip: bool = False,
) -> str:
"""URL 基础安全校验(纯逻辑,不含 DNS 解析).
校验项:
1. URL 非空 & 长度限制
2. Scheme 白名单
3. 主机名存在性
4. 内部主机名拦截
5. 端口白名单
6. 直接 IP 访问限制
7. IP 格式 SSRF 检查(如果 hostname 是 IP)
8. 可信域名白名单(如果配置了)
注意:域名格式的 SSRF 检查需要 DNS 解析,不在本函数范围内。
Args:
url: 待校验 URL
trusted_domains: 可信域名白名单,None/空表示不限制
allow_direct_ip: 是否允许直接 IP 访问
Returns:
原始 URL(校验通过)
Raises:
UrlSecurityError: 校验失败
"""
if not url:
raise UrlSecurityError("URL 为空")
if len(url) > MAX_URL_LENGTH:
raise UrlSecurityError(f"URL 过长 ({len(url)} > {MAX_URL_LENGTH})")
try:
parsed = urlparse(url)
except Exception as e:
raise UrlSecurityError(f"URL 解析失败: {e}") from e
# Scheme
if not parsed.scheme or parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise UrlSecurityError(f"不允许的 URL scheme: {parsed.scheme}")
# Hostname
hostname = parsed.hostname
if not hostname:
raise UrlSecurityError("URL 缺少主机名")
# 内部主机名前置拦截
check_internal_hostname(hostname)
# 端口
port = parsed.port
if port is not None and port not in ALLOWED_PORTS:
raise UrlSecurityError(f"不允许的端口: {port}")
# IP 格式检查 & SSRF
if is_ip_address(hostname):
if not allow_direct_ip:
raise UrlSecurityError(f"禁止直接 IP 访问: {hostname}")
check_ssrf_ip(hostname)
# 可信域名白名单
if trusted_domains and not is_trusted_domain(hostname, trusted_domains):
raise UrlSecurityError(f"域名不在可信白名单中: {hostname}")
return url
def is_url_basic_safe(
url: str,
*,
trusted_domains: set[str] | None = None,
allow_direct_ip: bool = False,
) -> bool:
"""便捷函数:基础安全检查,不抛异常,返回 bool."""
try:
validate_url_basic(url, trusted_domains=trusted_domains, allow_direct_ip=allow_direct_ip)
return True
except UrlSecurityError:
return False
# ── 魔数校验 ───────────────────────────────────────────────────────────────
def validate_magic_number(header_bytes: bytes, allowed_mime_types: set[str]) -> None:
"""校验文件头魔数是否与允许的 MIME 类型匹配(纯函数).
读取 header_bytes,与 allowed_mime_types 对应格式的魔数逐一比对,
任一类型匹配即通过;全部不匹配则抛出 UrlSecurityError。
Args:
header_bytes: 文件头字节(建议至少 256 字节)
allowed_mime_types: 允许的 MIME 类型集合
Raises:
UrlSecurityError: 文件魔数与所有允许类型均不匹配
"""
# 收集所有允许类型对应的魔数签名
signatures: list[list[tuple[int, bytes]]] = []
for mime in allowed_mime_types:
sigs = MAGIC_NUMBERS.get(mime)
if sigs:
signatures.extend(sigs)
# 没有已知魔数的 MIME,跳过不阻断
if not signatures:
return
if not header_bytes:
raise UrlSecurityError("文件为空,无法校验格式")
# 任一签名匹配即通过
for sig in signatures:
match = True
for offset, expected in sig:
if offset + len(expected) > len(header_bytes):
match = False
break
if header_bytes[offset : offset + len(expected)] != expected:
match = False
break
if match:
return
raise UrlSecurityError(
f"文件魔数与允许的 MIME 类型不匹配,"
f"允许类型: {sorted(allowed_mime_types)},"
f"文件头前16字节: {header_bytes[:16].hex()}"
)