Files
xiaoxia-saas/packages/domain/url_security.py
T
xiaoxia 06716a0678
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 1s
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
test(wave128): url_security纯逻辑抽离 + 110单测 (#1031)
2026-07-27 20:20:53 +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()}"
)