26db4ac44d
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
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
817 lines
30 KiB
Python
Executable File
817 lines
30 KiB
Python
Executable File
"""
|
||
url_security URL安全校验单元测试
|
||
|
||
覆盖:
|
||
- validate_url_safety: scheme/主机/端口/SSRF/内网域名/白名单
|
||
- is_url_safe: 便捷函数
|
||
- UrlSecurityError / NoRedirectHandler
|
||
- _validate_magic_number: 文件魔数校验
|
||
- safe_download_file / safe_download_bytes: mock 网络测试
|
||
"""
|
||
|
||
import ipaddress
|
||
import os
|
||
import tempfile
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from packages.shared.url_security import (
|
||
ALLOWED_AUDIO_MIME_TYPES,
|
||
ALLOWED_IMAGE_MIME_TYPES,
|
||
ALLOWED_PORTS,
|
||
ALLOWED_SCHEMES,
|
||
MAX_URL_LENGTH,
|
||
NoRedirectHandler,
|
||
UrlSecurityError,
|
||
_check_internal_hostnames,
|
||
_check_ssrf_ip,
|
||
_is_trusted_domain,
|
||
_validate_magic_number,
|
||
is_url_safe,
|
||
safe_download_bytes,
|
||
safe_download_file,
|
||
validate_url_safety,
|
||
)
|
||
|
||
# ── validate_url_safety 基础校验 ─────────────────────────────────────────────
|
||
|
||
|
||
class TestValidateUrlSafetyBasics:
|
||
"""URL 安全校验基础测试"""
|
||
|
||
def test_valid_http_url(self):
|
||
url = "http://example.com/file.mp4"
|
||
result = validate_url_safety(url)
|
||
assert result == url
|
||
|
||
def test_valid_https_url(self):
|
||
url = "https://example.com/file.mp4"
|
||
result = validate_url_safety(url)
|
||
assert result == url
|
||
|
||
def test_empty_url_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="为空"):
|
||
validate_url_safety("")
|
||
|
||
def test_none_url_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
validate_url_safety(None)
|
||
|
||
def test_url_too_long_raises(self):
|
||
long_url = "https://example.com/" + "a" * 2050
|
||
with pytest.raises(UrlSecurityError, match="过长"):
|
||
validate_url_safety(long_url)
|
||
|
||
def test_url_at_max_length_ok(self):
|
||
base = "https://example.com/"
|
||
pad = "a" * (MAX_URL_LENGTH - len(base))
|
||
url = base + pad
|
||
assert len(url) <= MAX_URL_LENGTH
|
||
result = validate_url_safety(url)
|
||
assert result == url
|
||
|
||
def test_invalid_scheme_ftp_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="scheme"):
|
||
validate_url_safety("ftp://example.com/file")
|
||
|
||
def test_invalid_scheme_file_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="scheme"):
|
||
validate_url_safety("file:///etc/passwd")
|
||
|
||
def test_invalid_scheme_data_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="scheme"):
|
||
validate_url_safety("data:text/html,<script>")
|
||
|
||
def test_missing_scheme_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="scheme"):
|
||
validate_url_safety("example.com/file")
|
||
|
||
def test_missing_hostname_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="主机名"):
|
||
validate_url_safety("http:///path")
|
||
|
||
def test_uppercase_scheme_normalized(self):
|
||
"""HTTP/HTTPS 大写也能通过"""
|
||
url = "HTTPS://example.com/file"
|
||
# scheme 检查用 lower 比较
|
||
result = validate_url_safety(url)
|
||
assert result == url
|
||
|
||
def test_default_port_80_ok(self):
|
||
url = "http://example.com:80/file"
|
||
result = validate_url_safety(url)
|
||
assert result == url
|
||
|
||
def test_default_port_443_ok(self):
|
||
url = "https://example.com:443/file"
|
||
result = validate_url_safety(url)
|
||
assert result == url
|
||
|
||
def test_non_standard_port_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="端口"):
|
||
validate_url_safety("http://example.com:8080/file")
|
||
|
||
def test_port_22_ssh_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="端口"):
|
||
validate_url_safety("http://example.com:22/file")
|
||
|
||
def test_port_3306_mysql_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="端口"):
|
||
validate_url_safety("http://example.com:3306/file")
|
||
|
||
|
||
# ── 内网主机名 / SSRF 防护 ───────────────────────────────────────────────────
|
||
|
||
|
||
class TestInternalHostnameProtection:
|
||
"""内网主机名防护测试"""
|
||
|
||
def test_localhost_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="内部主机名"):
|
||
validate_url_safety("http://localhost/file")
|
||
|
||
def test_localhost_mixed_case_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
validate_url_safety("http://LocalHost/file")
|
||
|
||
def test_localhost_localdomain_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_internal_hostnames("localhost.localdomain")
|
||
|
||
def test_metadata_hostname_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_internal_hostnames("metadata")
|
||
|
||
def test_metadata_google_internal_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_internal_hostnames("metadata.google.internal")
|
||
|
||
def test_dot_local_domain_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="内网域名"):
|
||
validate_url_safety("http://myservice.local/file")
|
||
|
||
def test_dot_internal_domain_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="内网域名"):
|
||
validate_url_safety("http://myservice.internal/file")
|
||
|
||
def test_dot_localdomain_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_internal_hostnames("server.localdomain")
|
||
|
||
def test_loopback_ip_127_0_0_1_raises(self):
|
||
with pytest.raises(UrlSecurityError, match="直接 IP|回环"):
|
||
validate_url_safety("http://127.0.0.1/file")
|
||
|
||
def test_metadata_ip_169_254_raises(self):
|
||
"""云元数据服务 IP"""
|
||
with pytest.raises(UrlSecurityError):
|
||
validate_url_safety("http://169.254.169.254/latest/meta-data/")
|
||
|
||
def test_private_ip_10_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
validate_url_safety("http://10.0.0.1/file")
|
||
|
||
def test_private_ip_172_16_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
validate_url_safety("http://172.16.0.1/file")
|
||
|
||
def test_private_ip_192_168_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
validate_url_safety("http://192.168.1.1/file")
|
||
|
||
def test_unspecified_ip_0_0_0_0_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
validate_url_safety("http://0.0.0.0/file")
|
||
|
||
def test_ipv6_loopback_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
validate_url_safety("http://[::1]/file")
|
||
|
||
def test_public_ip_ok(self):
|
||
"""公网IP在ALLOW_DIRECT_IP默认关闭时应被拦截"""
|
||
# 默认 ALLOW_DIRECT_IP = false
|
||
with pytest.raises(UrlSecurityError, match="直接 IP"):
|
||
validate_url_safety("http://8.8.8.8/file")
|
||
|
||
|
||
# ── 可信域名白名单 ───────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestTrustedDomains:
|
||
"""可信域名白名单测试"""
|
||
|
||
def test_is_trusted_domain_exact_match(self):
|
||
with patch("packages.shared.url_security.TRUSTED_DOMAINS", {"example.com", "cdn.example.org"}):
|
||
# 重新加载模块以应用环境变量不太现实,直接测函数
|
||
# 直接改全局状态再还原
|
||
import packages.shared.url_security as mod
|
||
from packages.shared.url_security import _is_trusted_domain
|
||
|
||
original = mod.TRUSTED_DOMAINS
|
||
mod.TRUSTED_DOMAINS = {"example.com", "cdn.example.org"}
|
||
try:
|
||
assert _is_trusted_domain("example.com") is True
|
||
assert _is_trusted_domain("cdn.example.org") is True
|
||
finally:
|
||
mod.TRUSTED_DOMAINS = original
|
||
|
||
def test_is_trusted_domain_subdomain(self):
|
||
import packages.shared.url_security as mod
|
||
|
||
original = mod.TRUSTED_DOMAINS
|
||
mod.TRUSTED_DOMAINS = {"example.com"}
|
||
try:
|
||
assert mod._is_trusted_domain("sub.example.com") is True
|
||
assert mod._is_trusted_domain("a.b.example.com") is True
|
||
finally:
|
||
mod.TRUSTED_DOMAINS = original
|
||
|
||
def test_is_trusted_domain_no_match(self):
|
||
import packages.shared.url_security as mod
|
||
|
||
original = mod.TRUSTED_DOMAINS
|
||
mod.TRUSTED_DOMAINS = {"example.com"}
|
||
try:
|
||
assert mod._is_trusted_domain("other.com") is False
|
||
assert mod._is_trusted_domain("notexample.com") is False
|
||
finally:
|
||
mod.TRUSTED_DOMAINS = original
|
||
|
||
def test_validate_with_trusted_domains_restricted(self):
|
||
"""白名单非空时,不在白名单中的域名被拒"""
|
||
import packages.shared.url_security as mod
|
||
|
||
original = mod.TRUSTED_DOMAINS
|
||
mod.TRUSTED_DOMAINS = {"trusted.com"}
|
||
try:
|
||
# 不在白名单中 - 在 _is_trusted_domain 检查时就被拒,不走 DNS
|
||
with pytest.raises(UrlSecurityError, match="白名单"):
|
||
validate_url_safety("https://untrusted.com/file")
|
||
|
||
# 在白名单中 - 需要 mock DNS 解析避免实际网络请求
|
||
with patch("packages.shared.url_security._check_ssrf_domain"):
|
||
result = validate_url_safety("https://trusted.com/file")
|
||
assert result == "https://trusted.com/file"
|
||
# 子域名
|
||
result = validate_url_safety("https://sub.trusted.com/file")
|
||
assert result == "https://sub.trusted.com/file"
|
||
finally:
|
||
mod.TRUSTED_DOMAINS = original
|
||
|
||
|
||
# ── is_url_safe 便捷函数 ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestIsUrlSafe:
|
||
"""is_url_safe 便捷函数测试"""
|
||
|
||
def test_safe_url_returns_true(self):
|
||
assert is_url_safe("https://example.com/file") is True
|
||
|
||
def test_unsafe_url_returns_false(self):
|
||
assert is_url_safe("http://localhost/file") is False
|
||
|
||
def test_empty_url_returns_false(self):
|
||
assert is_url_safe("") is False
|
||
|
||
def test_invalid_scheme_returns_false(self):
|
||
assert is_url_safe("ftp://example.com/file") is False
|
||
|
||
|
||
# ── UrlSecurityError 异常类 ───────────────────────────────────────────────────
|
||
|
||
|
||
class TestUrlSecurityError:
|
||
"""UrlSecurityError 异常类测试"""
|
||
|
||
def test_is_value_error_subclass(self):
|
||
assert issubclass(UrlSecurityError, ValueError)
|
||
|
||
def test_error_message(self):
|
||
err = UrlSecurityError("test message")
|
||
assert str(err) == "test message"
|
||
|
||
|
||
# ── NoRedirectHandler ────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestNoRedirectHandler:
|
||
"""NoRedirectHandler 测试"""
|
||
|
||
def test_redirect_request_returns_none(self):
|
||
handler = NoRedirectHandler()
|
||
result = handler.redirect_request(
|
||
MagicMock(),
|
||
MagicMock(),
|
||
302,
|
||
"Found",
|
||
{"Location": "http://other.com"},
|
||
"http://other.com",
|
||
)
|
||
assert result is None
|
||
|
||
|
||
# ── 魔数校验 ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestMagicNumberValidation:
|
||
"""文件魔数校验测试"""
|
||
|
||
def test_valid_png(self, tmp_path):
|
||
f = tmp_path / "test.png"
|
||
# PNG 文件头: 89 50 4E 47 0D 0A 1A 0A
|
||
f.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||
# 不抛异常 = 通过
|
||
_validate_magic_number(str(f), ALLOWED_IMAGE_MIME_TYPES)
|
||
|
||
def test_valid_jpeg(self, tmp_path):
|
||
f = tmp_path / "test.jpg"
|
||
# JPEG 文件头: FF D8 FF
|
||
f.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||
_validate_magic_number(str(f), ALLOWED_IMAGE_MIME_TYPES)
|
||
|
||
def test_valid_gif87a(self, tmp_path):
|
||
f = tmp_path / "test.gif"
|
||
f.write_bytes(b"GIF87a" + b"\x00" * 100)
|
||
_validate_magic_number(str(f), ALLOWED_IMAGE_MIME_TYPES)
|
||
|
||
def test_valid_gif89a(self, tmp_path):
|
||
f = tmp_path / "test.gif"
|
||
f.write_bytes(b"GIF89a" + b"\x00" * 100)
|
||
_validate_magic_number(str(f), ALLOWED_IMAGE_MIME_TYPES)
|
||
|
||
def test_valid_webp(self, tmp_path):
|
||
f = tmp_path / "test.webp"
|
||
# RIFF....WEBP
|
||
data = bytearray(b"RIFF")
|
||
data += b"\x00\x00\x00\x00" # size placeholder
|
||
data += b"WEBP"
|
||
data += b"\x00" * 100
|
||
f.write_bytes(bytes(data))
|
||
_validate_magic_number(str(f), ALLOWED_IMAGE_MIME_TYPES)
|
||
|
||
def test_valid_bmp(self, tmp_path):
|
||
f = tmp_path / "test.bmp"
|
||
f.write_bytes(b"BM" + b"\x00" * 100)
|
||
_validate_magic_number(str(f), ALLOWED_IMAGE_MIME_TYPES)
|
||
|
||
def test_valid_wav(self, tmp_path):
|
||
f = tmp_path / "test.wav"
|
||
# RIFF....WAVE
|
||
data = bytearray(b"RIFF")
|
||
data += b"\x00\x00\x00\x00"
|
||
data += b"WAVE"
|
||
data += b"\x00" * 100
|
||
f.write_bytes(bytes(data))
|
||
_validate_magic_number(str(f), ALLOWED_AUDIO_MIME_TYPES)
|
||
|
||
def test_valid_mp3_id3(self, tmp_path):
|
||
f = tmp_path / "test.mp3"
|
||
f.write_bytes(b"ID3\x03\x00\x00\x00\x00\x00\x00" + b"\x00" * 100)
|
||
_validate_magic_number(str(f), ALLOWED_AUDIO_MIME_TYPES)
|
||
|
||
def test_valid_mp3_adts(self, tmp_path):
|
||
f = tmp_path / "test.mp3"
|
||
f.write_bytes(b"\xff\xfb\x90\x00" + b"\x00" * 100)
|
||
_validate_magic_number(str(f), ALLOWED_AUDIO_MIME_TYPES)
|
||
|
||
def test_valid_ogg(self, tmp_path):
|
||
f = tmp_path / "test.ogg"
|
||
f.write_bytes(b"OggS\x00\x02\x00\x00" + b"\x00" * 100)
|
||
_validate_magic_number(str(f), ALLOWED_AUDIO_MIME_TYPES)
|
||
|
||
def test_valid_flac(self, tmp_path):
|
||
f = tmp_path / "test.flac"
|
||
f.write_bytes(b"fLaC" + b"\x00" * 100)
|
||
_validate_magic_number(str(f), ALLOWED_AUDIO_MIME_TYPES)
|
||
|
||
def test_invalid_file_content_raises(self, tmp_path):
|
||
f = tmp_path / "test.bin"
|
||
f.write_bytes(b"this is not an image file at all")
|
||
with pytest.raises(UrlSecurityError, match="魔数"):
|
||
_validate_magic_number(str(f), ALLOWED_IMAGE_MIME_TYPES)
|
||
|
||
def test_empty_file_raises(self, tmp_path):
|
||
f = tmp_path / "empty.bin"
|
||
f.write_bytes(b"")
|
||
with pytest.raises(UrlSecurityError, match="为空"):
|
||
_validate_magic_number(str(f), ALLOWED_IMAGE_MIME_TYPES)
|
||
|
||
def test_nonexistent_file_raises(self, tmp_path):
|
||
with pytest.raises(UrlSecurityError, match="读取文件头失败"):
|
||
_validate_magic_number(str(tmp_path / "no_such_file"), ALLOWED_IMAGE_MIME_TYPES)
|
||
|
||
def test_no_allowed_mime_types_skips(self, tmp_path):
|
||
"""allowed_mime_types 为空时跳过校验"""
|
||
f = tmp_path / "test.bin"
|
||
f.write_bytes(b"random data here")
|
||
# 不抛异常
|
||
_validate_magic_number(str(f), set())
|
||
|
||
def test_unknown_mime_types_skips(self, tmp_path):
|
||
"""没有已知魔数的 MIME 类型跳过校验"""
|
||
f = tmp_path / "test.bin"
|
||
f.write_bytes(b"random data")
|
||
_validate_magic_number(str(f), {"application/x-unknown-type"})
|
||
|
||
|
||
# ── safe_download_file (mock 网络) ───────────────────────────────────────────
|
||
|
||
|
||
class TestSafeDownloadFile:
|
||
"""safe_download_file 下载测试(mock 网络)"""
|
||
|
||
def test_download_success(self, tmp_path):
|
||
test_content = b"Hello, this is test file content!"
|
||
dest = str(tmp_path / "output.bin")
|
||
|
||
mock_resp = MagicMock()
|
||
mock_resp.headers = {"Content-Type": "application/octet-stream"}
|
||
mock_resp.read.side_effect = [test_content, b""]
|
||
|
||
with patch("packages.shared.url_security.NoRedirectHandler") as mock_handler_cls:
|
||
mock_handler = MagicMock()
|
||
mock_handler_cls.return_value = mock_handler
|
||
|
||
mock_opener = MagicMock()
|
||
mock_opener.open.return_value = mock_resp
|
||
|
||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||
size = safe_download_file(
|
||
"https://example.com/test.bin",
|
||
dest,
|
||
purpose="test",
|
||
)
|
||
|
||
assert size == len(test_content)
|
||
with open(dest, "rb") as f:
|
||
assert f.read() == test_content
|
||
|
||
def test_download_with_mime_check_passes(self, tmp_path):
|
||
# PNG 文件
|
||
test_content = b"\x89PNG\r\n\x1a\n" + b"\x00" * 200
|
||
dest = str(tmp_path / "test.png")
|
||
|
||
mock_resp = MagicMock()
|
||
mock_resp.headers = {"Content-Type": "image/png"}
|
||
mock_resp.read.side_effect = [test_content, b""]
|
||
|
||
with patch("urllib.request.build_opener") as mock_build:
|
||
mock_opener = MagicMock()
|
||
mock_opener.open.return_value = mock_resp
|
||
mock_build.return_value = mock_opener
|
||
|
||
size = safe_download_file(
|
||
"https://example.com/test.png",
|
||
dest,
|
||
purpose="test",
|
||
allowed_mime_types={"image/png", "image/jpeg"},
|
||
)
|
||
|
||
assert size == len(test_content)
|
||
|
||
def test_download_mime_type_rejected(self, tmp_path):
|
||
test_content = b"GIF89a" + b"\x00" * 50
|
||
dest = str(tmp_path / "test.gif")
|
||
|
||
mock_resp = MagicMock()
|
||
mock_resp.headers = {"Content-Type": "image/gif"}
|
||
mock_resp.read.side_effect = [test_content, b""]
|
||
|
||
with patch("urllib.request.build_opener") as mock_build:
|
||
mock_opener = MagicMock()
|
||
mock_opener.open.return_value = mock_resp
|
||
mock_build.return_value = mock_opener
|
||
|
||
with pytest.raises(UrlSecurityError, match="Content-Type"):
|
||
safe_download_file(
|
||
"https://example.com/test.gif",
|
||
dest,
|
||
purpose="test",
|
||
allowed_mime_types={"image/png"},
|
||
)
|
||
|
||
def test_download_size_limit_exceeded(self, tmp_path):
|
||
"""流式下载时超过大小限制被中断(无 Content-Length header)"""
|
||
dest = str(tmp_path / "big.bin")
|
||
chunk = b"x" * 1024 # 1KB chunks
|
||
|
||
mock_resp = MagicMock()
|
||
# 没有 Content-Length header,走流式检查
|
||
mock_resp.headers = {"Content-Type": "application/octet-stream"}
|
||
# 模拟多次读取,超过 5KB 限制(6个chunk = 6KB)
|
||
mock_resp.read.side_effect = [chunk] * 6 + [b""]
|
||
|
||
with patch("urllib.request.build_opener") as mock_build:
|
||
mock_opener = MagicMock()
|
||
mock_opener.open.return_value = mock_resp
|
||
mock_build.return_value = mock_opener
|
||
|
||
with pytest.raises(UrlSecurityError, match="超过大小限制"):
|
||
safe_download_file(
|
||
"https://example.com/big.bin",
|
||
dest,
|
||
purpose="test",
|
||
max_size=5000, # 5KB limit
|
||
)
|
||
|
||
def test_download_content_length_too_large(self, tmp_path):
|
||
dest = str(tmp_path / "big.bin")
|
||
|
||
mock_resp = MagicMock()
|
||
mock_resp.headers = {"Content-Type": "application/octet-stream", "Content-Length": "1000000"}
|
||
mock_resp.read.side_effect = [b"data"]
|
||
|
||
with patch("urllib.request.build_opener") as mock_build:
|
||
mock_opener = MagicMock()
|
||
mock_opener.open.return_value = mock_resp
|
||
mock_build.return_value = mock_opener
|
||
|
||
with pytest.raises(UrlSecurityError, match="文件过大"):
|
||
safe_download_file(
|
||
"https://example.com/big.bin",
|
||
dest,
|
||
purpose="test",
|
||
max_size=500000,
|
||
)
|
||
|
||
def test_download_localhost_rejected(self, tmp_path):
|
||
"""内网 URL 在下载前就被拒"""
|
||
dest = str(tmp_path / "out.bin")
|
||
with pytest.raises(UrlSecurityError):
|
||
safe_download_file("http://localhost/file", dest)
|
||
|
||
def test_download_invalid_scheme_rejected(self, tmp_path):
|
||
dest = str(tmp_path / "out.bin")
|
||
with pytest.raises(UrlSecurityError, match="scheme"):
|
||
safe_download_file("ftp://example.com/file", dest)
|
||
|
||
|
||
# ── safe_download_bytes ───────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSafeDownloadBytes:
|
||
"""safe_download_bytes 测试"""
|
||
|
||
def test_download_returns_bytes(self, tmp_path):
|
||
test_content = b"hello bytes download test"
|
||
|
||
mock_resp = MagicMock()
|
||
mock_resp.headers = {"Content-Type": "application/octet-stream"}
|
||
mock_resp.read.side_effect = [test_content, b""]
|
||
|
||
with patch("urllib.request.build_opener") as mock_build:
|
||
mock_opener = MagicMock()
|
||
mock_opener.open.return_value = mock_resp
|
||
mock_build.return_value = mock_opener
|
||
|
||
result = safe_download_bytes(
|
||
"https://example.com/test.bin",
|
||
purpose="test",
|
||
)
|
||
|
||
assert result == test_content
|
||
assert isinstance(result, bytes)
|
||
|
||
def test_download_unsafe_url_raises(self):
|
||
with pytest.raises(UrlSecurityError):
|
||
safe_download_bytes("http://127.0.0.1/secret")
|
||
|
||
|
||
# ── 常量导出验证 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestConstants:
|
||
"""模块常量验证"""
|
||
|
||
def test_allowed_schemes(self):
|
||
assert "http" in ALLOWED_SCHEMES
|
||
assert "https" in ALLOWED_SCHEMES
|
||
assert len(ALLOWED_SCHEMES) == 2
|
||
|
||
def test_allowed_ports(self):
|
||
assert 80 in ALLOWED_PORTS
|
||
assert 443 in ALLOWED_PORTS
|
||
assert len(ALLOWED_PORTS) == 2
|
||
|
||
def test_max_url_length(self):
|
||
assert MAX_URL_LENGTH == 2048
|
||
|
||
|
||
# ── SSRF IP 检查详细覆盖 ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSSRFIPCheck:
|
||
"""_check_ssrf_ip 各类型 IP 拦截覆盖."""
|
||
|
||
def test_loopback_ipv4_blocked(self):
|
||
"""IPv4 回环 127.0.0.1 被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("127.0.0.1"))
|
||
|
||
def test_loopback_ipv4_another_blocked(self):
|
||
"""127.x 其他段也被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("127.255.255.1"))
|
||
|
||
def test_loopback_ipv6_blocked(self):
|
||
"""IPv6 回环 ::1 被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("::1"))
|
||
|
||
def test_private_10_range_blocked(self):
|
||
"""10.0.0.0/8 私有段被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("10.255.255.255"))
|
||
|
||
def test_private_172_range_blocked(self):
|
||
"""172.16.0.0/12 私有段被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("172.31.255.255"))
|
||
|
||
def test_private_192_range_blocked(self):
|
||
"""192.168.0.0/16 私有段被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("192.168.255.255"))
|
||
|
||
def test_link_local_ipv4_blocked(self):
|
||
"""169.254.x.x 链路本地被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("169.254.1.1"))
|
||
|
||
def test_multicast_ipv4_blocked(self):
|
||
"""224.x 组播被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("224.0.0.251"))
|
||
|
||
def test_unspecified_ipv4_blocked(self):
|
||
"""0.0.0.0 未指定地址被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("0.0.0.0"))
|
||
|
||
def test_unspecified_ipv6_blocked(self):
|
||
""":: 未指定地址被拦."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("::"))
|
||
|
||
def test_reserved_ipv4_blocked(self):
|
||
"""240.0.0.0/4 保留段被拦(含在 is_private 或 is_reserved 中)."""
|
||
with pytest.raises(UrlSecurityError):
|
||
_check_ssrf_ip(ipaddress.ip_address("240.0.0.1"))
|
||
|
||
def test_public_ipv4_passes(self):
|
||
"""公网 IPv4 通过 _check_ssrf_ip."""
|
||
_check_ssrf_ip(ipaddress.ip_address("8.8.8.8"))
|
||
|
||
def test_public_ipv4_another_passes(self):
|
||
"""另一个公网 IPv4 通过."""
|
||
_check_ssrf_ip(ipaddress.ip_address("1.1.1.1"))
|
||
|
||
def test_public_ipv6_passes(self):
|
||
"""公网 IPv6 通过."""
|
||
_check_ssrf_ip(ipaddress.ip_address("2001:4860:4860::8888"))
|
||
|
||
|
||
# ── 直接 IP 访问拦截 ────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestDirectIPAccess:
|
||
"""直接 IP 访问控制(ALLOW_DIRECT_IP 开关)."""
|
||
|
||
def test_direct_ipv4_blocked_by_default(self):
|
||
"""默认禁止直接 IP 访问."""
|
||
with pytest.raises(UrlSecurityError, match="禁止直接 IP 访问"):
|
||
validate_url_safety("https://8.8.8.8/audio.mp3")
|
||
|
||
def test_direct_private_ip_blocked_even_with_flag(self):
|
||
"""ALLOW_DIRECT_IP=true 时私有 IP 仍被 SSRF 拦."""
|
||
with patch("packages.shared.url_security.ALLOW_DIRECT_IP", True):
|
||
with pytest.raises(UrlSecurityError):
|
||
validate_url_safety("https://192.168.1.1/a.mp3")
|
||
|
||
def test_direct_ip_allowed_when_flag_on(self):
|
||
"""ALLOW_DIRECT_IP=true 时公网 IP 通过."""
|
||
with patch("packages.shared.url_security.ALLOW_DIRECT_IP", True):
|
||
# 用 mock 绕过 DNS 解析路径,走 IP 分支
|
||
with patch("packages.shared.url_security._check_ssrf_ip") as mock_check:
|
||
result = validate_url_safety("https://8.8.8.8/a.mp3")
|
||
assert "8.8.8.8" in result
|
||
mock_check.assert_called_once()
|
||
|
||
|
||
# ── 魔数校验补充覆盖 ────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestMagicNumberExtended:
|
||
"""魔数校验补充:更多格式 + 边界场景."""
|
||
|
||
def test_aac_adts_mpeg4(self):
|
||
"""AAC ADTS MPEG-4 魔数通过."""
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"\xff\xf1" + b"\x00" * 50)
|
||
tmp = f.name
|
||
try:
|
||
_validate_magic_number(tmp, {"audio/aac"})
|
||
finally:
|
||
os.unlink(tmp)
|
||
|
||
def test_m4a_ftyp_magic(self):
|
||
"""M4A ftyp 魔数通过."""
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"\x00\x00\x00\x20ftypM4A " + b"\x00" * 50)
|
||
tmp = f.name
|
||
try:
|
||
_validate_magic_number(tmp, {"audio/x-m4a"})
|
||
finally:
|
||
os.unlink(tmp)
|
||
|
||
def test_webp_riff_webp(self):
|
||
"""WebP RIFF+WEBP 魔数通过."""
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 50)
|
||
tmp = f.name
|
||
try:
|
||
_validate_magic_number(tmp, {"image/webp"})
|
||
finally:
|
||
os.unlink(tmp)
|
||
|
||
def test_video_mp4_magic(self):
|
||
"""video/mp4 ftyp 魔数通过."""
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"\x00\x00\x00\x20ftypmp42" + b"\x00" * 50)
|
||
tmp = f.name
|
||
try:
|
||
_validate_magic_number(tmp, {"video/mp4"})
|
||
finally:
|
||
os.unlink(tmp)
|
||
|
||
def test_matroska_webm_magic(self):
|
||
"""WebM EBML 魔数通过."""
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"\x1a\x45\xdf\xa3" + b"\x00" * 50)
|
||
tmp = f.name
|
||
try:
|
||
_validate_magic_number(tmp, {"video/webm"})
|
||
finally:
|
||
os.unlink(tmp)
|
||
|
||
def test_bmp_magic(self):
|
||
"""BMP BM 魔数通过."""
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"BM" + b"\x00" * 50)
|
||
tmp = f.name
|
||
try:
|
||
_validate_magic_number(tmp, {"image/bmp"})
|
||
finally:
|
||
os.unlink(tmp)
|
||
|
||
def test_flac_magic(self):
|
||
"""FLAC fLaC 魔数通过."""
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"fLaC" + b"\x00" * 50)
|
||
tmp = f.name
|
||
try:
|
||
_validate_magic_number(tmp, {"audio/flac"})
|
||
finally:
|
||
os.unlink(tmp)
|
||
|
||
def test_ogg_magic(self):
|
||
"""OGG OggS 魔数通过."""
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"OggS" + b"\x00" * 50)
|
||
tmp = f.name
|
||
try:
|
||
_validate_magic_number(tmp, {"audio/ogg"})
|
||
finally:
|
||
os.unlink(tmp)
|
||
|
||
def test_read_error_raises_security_error(self):
|
||
"""文件读取失败包装为 UrlSecurityError."""
|
||
with pytest.raises(UrlSecurityError, match="读取文件头失败"):
|
||
_validate_magic_number("/nonexistent/path/file.mp3", {"audio/mpeg"})
|
||
|
||
def test_multi_type_one_match(self):
|
||
"""多类型白名单,只要一个匹配就通过."""
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"\xff\xd8\xff" + b"\x00" * 50)
|
||
tmp = f.name
|
||
try:
|
||
_validate_magic_number(tmp, {"image/png", "image/jpeg", "image/gif"})
|
||
finally:
|
||
os.unlink(tmp)
|
||
|
||
def test_application_octet_stream_skipped(self):
|
||
"""application/octet-stream 没有专属魔数,跳过校验."""
|
||
# 注意:octet-stream 在 _MAGIC_NUMBERS 中没有条目,所以跳过
|
||
# 但实际白名单中常包含它,所以它的存在不应阻断
|
||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||
f.write(b"random stuff")
|
||
tmp = f.name
|
||
try:
|
||
# octet-stream + png,png有魔数,png不匹配就会失败
|
||
# 只有 octet-stream 时应该跳过
|
||
_validate_magic_number(tmp, {"application/octet-stream"})
|
||
finally:
|
||
os.unlink(tmp)
|