Files
xiaoxia-saas/tests/unit/test_url_security.py
T
CI Bot 37b3bf8db2 test: 第73波 url_security + pagination + text_splitter 单测 (+144)
- test_url_security: URL安全校验(SSRF防护/魔数校验/可信域名/直接IP拦截) ~85个
- test_pagination: 分页器(参数校验/偏移计算/元数据/内存分页) ~35个
- test_text_splitter: 文本分段器(句子边界/强制切段/短段合并) ~24个

全部纯逻辑,无外部依赖
2026-07-25 10:21:01 +08:00

533 lines
21 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防护/魔数校验/可信域名.
聚焦纯函数校验逻辑,DNS解析和实际下载用mock隔离。
"""
from __future__ import annotations
import ipaddress
import os
import tempfile
from unittest.mock import patch
import pytest
from shared.url_security import (
ALLOWED_PORTS,
ALLOWED_SCHEMES,
UrlSecurityError,
_check_internal_hostnames,
_check_ssrf_ip,
_is_trusted_domain,
_validate_magic_number,
is_url_safe,
validate_url_safety,
)
class TestUrlSecurityError:
"""UrlSecurityError 异常类."""
def test_is_value_error(self):
"""继承自 ValueError."""
assert issubclass(UrlSecurityError, ValueError)
def test_message_preserved(self):
"""错误消息被保留."""
err = UrlSecurityError("test message")
assert str(err) == "test message"
class TestValidateUrlSafetyBasic:
"""validate_url_safety 基础校验(不涉及DNS解析)."""
def test_empty_url_rejected(self):
"""空URL被拒绝."""
with pytest.raises(UrlSecurityError, match="URL 为空"):
validate_url_safety("")
def test_none_url_rejected(self):
"""None URL被拒绝."""
with pytest.raises(UrlSecurityError):
validate_url_safety(None)
def test_url_too_long_rejected(self):
"""超长URL被拒绝."""
long_url = "https://example.com/" + "a" * 2048
with pytest.raises(UrlSecurityError, match="URL 过长"):
validate_url_safety(long_url)
def test_invalid_scheme_rejected(self):
"""非法scheme被拒绝."""
with pytest.raises(UrlSecurityError, match="不允许的 URL scheme"):
validate_url_safety("ftp://example.com/file.mp3")
def test_file_scheme_rejected(self):
"""file:// scheme被拒绝."""
with pytest.raises(UrlSecurityError, match="不允许的 URL scheme"):
validate_url_safety("file:///etc/passwd")
def test_no_scheme_rejected(self):
"""无scheme被拒绝."""
with pytest.raises(UrlSecurityError, match="不允许的 URL scheme"):
validate_url_safety("example.com/file.mp3")
def test_http_scheme_allowed(self):
"""http scheme允许(需要mock DNS."""
with patch("shared.url_security._check_ssrf_domain") as mock_check:
result = validate_url_safety("http://example.com/audio.mp3")
assert result == "http://example.com/audio.mp3"
mock_check.assert_called_once()
def test_https_scheme_allowed(self):
"""https scheme允许."""
with patch("shared.url_security._check_ssrf_domain") as mock_check:
result = validate_url_safety("https://example.com/audio.mp3")
assert result == "https://example.com/audio.mp3"
mock_check.assert_called_once()
def test_scheme_case_insensitive(self):
"""scheme大小写不敏感."""
with patch("shared.url_security._check_ssrf_domain") as mock_check:
result = validate_url_safety("HTTPS://example.com/audio.mp3")
assert result == "HTTPS://example.com/audio.mp3"
def test_missing_hostname_rejected(self):
"""缺少主机名被拒绝."""
with pytest.raises(UrlSecurityError, match="URL 缺少主机名"):
validate_url_safety("https:///path/to/file")
def test_non_standard_port_rejected(self):
"""非标准端口被拒绝."""
with pytest.raises(UrlSecurityError, match="不允许的端口"):
validate_url_safety("https://example.com:8080/audio.mp3")
def test_port_22_rejected(self):
"""SSH端口被拒绝."""
with pytest.raises(UrlSecurityError, match="不允许的端口"):
validate_url_safety("https://example.com:22/")
def test_port_80_allowed(self):
"""80端口允许."""
with patch("shared.url_security._check_ssrf_domain") as mock_check:
result = validate_url_safety("http://example.com:80/audio.mp3")
assert result == "http://example.com:80/audio.mp3"
def test_port_443_allowed(self):
"""443端口允许."""
with patch("shared.url_security._check_ssrf_domain") as mock_check:
result = validate_url_safety("https://example.com:443/audio.mp3")
assert result == "https://example.com:443/audio.mp3"
def test_default_http_port_implicit_80(self):
"""http默认端口隐含80,不触发端口校验."""
with patch("shared.url_security._check_ssrf_domain") as mock_check:
result = validate_url_safety("http://example.com/audio.mp3")
assert "example.com" in result
def test_default_https_port_implicit_443(self):
"""https默认端口隐含443,不触发端口校验."""
with patch("shared.url_security._check_ssrf_domain") as mock_check:
result = validate_url_safety("https://example.com/audio.mp3")
assert "example.com" in result
def test_purpose_parameter_logged(self):
"""purpose参数不影响校验结果."""
with patch("shared.url_security._check_ssrf_domain"):
result = validate_url_safety("https://example.com/a.mp3", purpose="tts_download")
assert result == "https://example.com/a.mp3"
def test_url_with_path_and_query(self):
"""带路径和query参数的URL正常通过."""
with patch("shared.url_security._check_ssrf_domain"):
url = "https://example.com/path/to/file.mp3?token=abc&expires=123"
result = validate_url_safety(url)
assert result == url
class TestInternalHostnames:
"""_check_internal_hostnames 内部主机名拦截."""
def test_localhost_blocked(self):
"""localhost被拦截."""
with pytest.raises(UrlSecurityError, match="禁止访问内部主机名"):
_check_internal_hostnames("localhost")
def test_localhost_case_insensitive(self):
"""大小写不敏感."""
with pytest.raises(UrlSecurityError):
_check_internal_hostnames("LOCALHOST")
def test_localhost_localdomain_blocked(self):
"""localhost.localdomain被拦截."""
with pytest.raises(UrlSecurityError):
_check_internal_hostnames("localhost.localdomain")
def test_metadata_blocked(self):
"""metadata被拦截."""
with pytest.raises(UrlSecurityError):
_check_internal_hostnames("metadata")
def test_metadata_google_internal_blocked(self):
"""GCP元数据服务被拦截."""
with pytest.raises(UrlSecurityError):
_check_internal_hostnames("metadata.google.internal")
def test_local_domain_suffix_blocked(self):
""".local后缀被拦截."""
with pytest.raises(UrlSecurityError, match="禁止访问内网域名"):
_check_internal_hostnames("printer.local")
def test_internal_domain_suffix_blocked(self):
""".internal后缀被拦截."""
with pytest.raises(UrlSecurityError, match="禁止访问内网域名"):
_check_internal_hostnames("service.internal")
def test_localdomain_suffix_blocked(self):
""".localdomain后缀被拦截."""
with pytest.raises(UrlSecurityError, match="禁止访问内网域名"):
_check_internal_hostnames("host.localdomain")
def test_public_domain_passes(self):
"""公网域名通过."""
_check_internal_hostnames("example.com") # 不抛异常即通过
def test_subdomain_of_public_domain_passes(self):
"""公网域名子域名通过."""
_check_internal_hostnames("cdn.example.com") # 不抛异常即通过
class TestSSRFIPCheck:
"""_check_ssrf_ip SSRF IP检查."""
def test_loopback_ipv4_blocked(self):
"""IPv4回环地址被拦截."""
with pytest.raises(UrlSecurityError, match="回环地址"):
_check_ssrf_ip(ipaddress.ip_address("127.0.0.1"))
def test_loopback_ipv6_blocked(self):
"""IPv6回环地址被拦截."""
with pytest.raises(UrlSecurityError, match="回环地址"):
_check_ssrf_ip(ipaddress.ip_address("::1"))
def test_private_ip_10_blocked(self):
"""10.x.x.x私有地址被拦截."""
with pytest.raises(UrlSecurityError, match="内网地址"):
_check_ssrf_ip(ipaddress.ip_address("10.0.0.1"))
def test_private_ip_172_blocked(self):
"""172.16.x.x私有地址被拦截."""
with pytest.raises(UrlSecurityError, match="内网地址"):
_check_ssrf_ip(ipaddress.ip_address("172.16.0.1"))
def test_private_ip_192_blocked(self):
"""192.168.x.x私有地址被拦截."""
with pytest.raises(UrlSecurityError, match="内网地址"):
_check_ssrf_ip(ipaddress.ip_address("192.168.1.1"))
def test_link_local_blocked(self):
"""链路本地地址被拦截(is_private 先命中也可以,只要被拦就行)."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("169.254.169.254"))
def test_multicast_blocked(self):
"""组播地址被拦截."""
with pytest.raises(UrlSecurityError, match="组播地址"):
_check_ssrf_ip(ipaddress.ip_address("224.0.0.1"))
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_ip_blocked(self):
"""保留地址被拦截(240.0.0.0/4 属于保留段)."""
with pytest.raises(UrlSecurityError):
_check_ssrf_ip(ipaddress.ip_address("240.0.0.1"))
def test_public_ip_passes(self):
"""公网IP通过(当ALLOW_DIRECT_IP=true时)."""
# 注意:公网IP在_check_ssrf_ip层面不拦截,拦截在validate_url_safety的ALLOW_DIRECT_IP层
_check_ssrf_ip(ipaddress.ip_address("8.8.8.8")) # 不抛异常即通过
def test_public_ip_another_passes(self):
"""另一个公网IP通过."""
_check_ssrf_ip(ipaddress.ip_address("1.1.1.1")) # 不抛异常即通过
def test_ipv6_public_passes(self):
"""IPv6公网地址通过."""
_check_ssrf_ip(ipaddress.ip_address("2001:4860:4860::8888")) # 不抛异常即通过
class TestDirectIPBlocking:
"""直接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_ipv6_blocked_by_default(self):
"""默认禁止直接IPv6访问."""
with pytest.raises(UrlSecurityError, match="禁止直接 IP 访问"):
validate_url_safety("https://[2001:4860:4860::8888]/audio.mp3")
def test_direct_ip_allowed_when_flag_set(self):
"""ALLOW_DIRECT_IP=true时允许公网IP直接访问."""
with patch("shared.url_security.ALLOW_DIRECT_IP", True):
# 公网IP应该通过
with patch("shared.url_security._check_ssrf_ip") as mock_check:
result = validate_url_safety("https://8.8.8.8/audio.mp3")
assert result == "https://8.8.8.8/audio.mp3"
mock_check.assert_called_once()
def test_direct_private_ip_blocked_even_with_flag(self):
"""即使ALLOW_DIRECT_IP=true,私有IP仍被SSRF检查拦截."""
with patch("shared.url_security.ALLOW_DIRECT_IP", True):
with pytest.raises(UrlSecurityError, match="内网地址"):
validate_url_safety("https://192.168.1.1/audio.mp3")
class TestTrustedDomain:
"""_is_trusted_domain 可信域名匹配."""
def test_exact_match(self):
"""精确匹配."""
with patch("shared.url_security.TRUSTED_DOMAINS", {"example.com"}):
assert _is_trusted_domain("example.com") is True
def test_subdomain_match(self):
"""子域名匹配."""
with patch("shared.url_security.TRUSTED_DOMAINS", {"example.com"}):
assert _is_trusted_domain("cdn.example.com") is True
def test_nested_subdomain_match(self):
"""多级子域名匹配."""
with patch("shared.url_security.TRUSTED_DOMAINS", {"example.com"}):
assert _is_trusted_domain("a.b.c.example.com") is True
def test_no_match(self):
"""不匹配的域名."""
with patch("shared.url_security.TRUSTED_DOMAINS", {"example.com"}):
assert _is_trusted_domain("other.com") is False
def test_domain_containing_but_not_subdomain(self):
"""域名包含但不是子域名."""
with patch("shared.url_security.TRUSTED_DOMAINS", {"example.com"}):
assert _is_trusted_domain("fake-example.com") is False
def test_case_insensitive(self):
"""大小写不敏感."""
with patch("shared.url_security.TRUSTED_DOMAINS", {"Example.COM"}):
assert _is_trusted_domain("cdn.example.com") is True
def test_empty_trusted_domains(self):
"""空白名单时全部返回False."""
with patch("shared.url_security.TRUSTED_DOMAINS", set()):
assert _is_trusted_domain("anything.com") is False
def test_multiple_trusted_domains(self):
"""多个可信域名."""
with patch("shared.url_security.TRUSTED_DOMAINS", {"example.com", "aliyuncs.com"}):
assert _is_trusted_domain("bucket.oss-cn-hangzhou.aliyuncs.com") is True
assert _is_trusted_domain("cdn.example.com") is True
assert _is_trusted_domain("unknown.com") is False
class TestIsUrlSafe:
"""is_url_safe 便捷函数."""
def test_safe_url_returns_true(self):
"""安全URL返回True."""
with patch("shared.url_security._check_ssrf_domain"):
assert is_url_safe("https://example.com/a.mp3") is True
def test_unsafe_url_returns_false(self):
"""不安全URL返回False."""
assert is_url_safe("ftp://example.com/a.mp3") is False
def test_empty_url_returns_false(self):
"""空URL返回False."""
assert is_url_safe("") is False
def test_does_not_raise(self):
"""不抛异常."""
try:
is_url_safe("not a url at all")
except Exception:
pytest.fail("is_url_safe should not raise exceptions")
class TestMagicNumberValidation:
"""_validate_magic_number 文件魔数校验."""
def test_valid_mp3_id3v2(self):
"""MP3 ID3v2标签魔数通过."""
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
f.write(b"ID3" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"audio/mpeg"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_valid_wav(self):
"""WAV文件魔数通过."""
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
# RIFF + WAVE
f.write(b"RIFF" + b"\x00" * 4 + b"WAVE" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"audio/wav"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_valid_png(self):
"""PNG图片魔数通过."""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"image/png"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_valid_jpeg(self):
"""JPEG图片魔数通过."""
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(b"\xff\xd8\xff" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"image/jpeg"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_valid_gif87a(self):
"""GIF87a通过."""
with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as f:
f.write(b"GIF87a" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"image/gif"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_valid_gif89a(self):
"""GIF89a通过."""
with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as f:
f.write(b"GIF89a" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"image/gif"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_valid_ogg(self):
"""OGG音频通过."""
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
f.write(b"OggS" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"audio/ogg"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_invalid_magic_number_rejected(self):
"""错误魔数被拒绝."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
f.write(b"NOT_A_VALID_FILE" + b"\x00" * 100)
tmp_path = f.name
try:
with pytest.raises(UrlSecurityError, match="文件魔数与允许的 MIME 类型不匹配"):
_validate_magic_number(tmp_path, {"audio/mpeg", "image/png"})
finally:
os.unlink(tmp_path)
def test_empty_file_rejected(self):
"""空文件被拒绝."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
tmp_path = f.name
try:
with pytest.raises(UrlSecurityError, match="文件为空"):
_validate_magic_number(tmp_path, {"audio/mpeg"})
finally:
os.unlink(tmp_path)
def test_unknown_mime_skipped(self):
"""未知MIME类型跳过校验."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
f.write(b"random data here")
tmp_path = f.name
try:
# 未知MIME类型没有对应的魔数,应该跳过不阻断
_validate_magic_number(tmp_path, {"application/x-unknown-type"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_empty_allowed_mime_skipped(self):
"""空allowed_mime_types集合直接通过(无魔数可比对)."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
f.write(b"anything")
tmp_path = f.name
try:
_validate_magic_number(tmp_path, set()) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_multiple_allowed_types_one_matches(self):
"""多个允许类型,只要一个匹配就通过."""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"audio/mpeg", "image/png", "image/jpeg"})
finally:
os.unlink(tmp_path)
def test_mp4_ftyp_magic(self):
"""MP4 ftyp魔数通过."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
# ftyp在偏移4的位置
f.write(b"\x00\x00\x00\x20" + b"ftyp" + b"mp42" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"video/mp4"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_webp_magic(self):
"""WebP魔数通过."""
with tempfile.NamedTemporaryFile(suffix=".webp", delete=False) as f:
f.write(b"RIFF" + b"\x00" * 4 + b"WEBP" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"image/webp"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_flac_magic(self):
"""FLAC魔数通过."""
with tempfile.NamedTemporaryFile(suffix=".flac", delete=False) as f:
f.write(b"fLaC" + b"\x00" * 100)
tmp_path = f.name
try:
_validate_magic_number(tmp_path, {"audio/flac"}) # 不抛异常即通过
finally:
os.unlink(tmp_path)
def test_file_too_short_for_signature(self):
"""文件太短,不够签名长度时不匹配."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
f.write(b"ID") # 只有2字节,不够ID3的3字节
tmp_path = f.name
try:
with pytest.raises(UrlSecurityError, match="文件魔数"):
_validate_magic_number(tmp_path, {"audio/mpeg"})
finally:
os.unlink(tmp_path)