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
424 lines
15 KiB
Python
Executable File
424 lines
15 KiB
Python
Executable File
"""URL 安全校验纯逻辑单元测试 — wave128."""
|
|
|
|
import pytest
|
|
|
|
from packages.domain.url_security import (
|
|
ALLOWED_PORTS,
|
|
ALLOWED_SCHEMES,
|
|
MAGIC_NUMBERS,
|
|
MAX_URL_LENGTH,
|
|
UrlSecurityError,
|
|
check_internal_hostname,
|
|
check_ssrf_ip,
|
|
is_ip_address,
|
|
is_trusted_domain,
|
|
is_url_basic_safe,
|
|
validate_magic_number,
|
|
validate_url_basic,
|
|
)
|
|
|
|
# ── 常量校验 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestConstants:
|
|
def test_allowed_schemes(self):
|
|
assert "http" in ALLOWED_SCHEMES
|
|
assert "https" in ALLOWED_SCHEMES
|
|
|
|
def test_allowed_ports(self):
|
|
assert 80 in ALLOWED_PORTS
|
|
assert 443 in ALLOWED_PORTS
|
|
|
|
def test_max_url_length(self):
|
|
assert MAX_URL_LENGTH == 2048
|
|
|
|
def test_magic_numbers_has_common_formats(self):
|
|
assert "audio/mpeg" in MAGIC_NUMBERS
|
|
assert "image/png" in MAGIC_NUMBERS
|
|
assert "video/mp4" in MAGIC_NUMBERS
|
|
|
|
|
|
# ── 内部主机名检查 ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCheckInternalHostname:
|
|
@pytest.mark.parametrize(
|
|
"hostname",
|
|
[
|
|
"localhost",
|
|
"LOCALHOST",
|
|
"LocalHost",
|
|
"localhost.localdomain",
|
|
"ip6-localhost",
|
|
"ip6-loopback",
|
|
"metadata",
|
|
"metadata.google.internal",
|
|
"169.254.169.254",
|
|
],
|
|
)
|
|
def test_internal_hostnames_rejected(self, hostname):
|
|
with pytest.raises(UrlSecurityError, match="禁止访问内部主机名"):
|
|
check_internal_hostname(hostname)
|
|
|
|
@pytest.mark.parametrize(
|
|
"hostname",
|
|
[
|
|
"foo.local",
|
|
"bar.internal",
|
|
"baz.localdomain",
|
|
"sub.foo.local",
|
|
],
|
|
)
|
|
def test_internal_domain_suffixes_rejected(self, hostname):
|
|
with pytest.raises(UrlSecurityError, match="禁止访问内网域名"):
|
|
check_internal_hostname(hostname)
|
|
|
|
@pytest.mark.parametrize(
|
|
"hostname",
|
|
[
|
|
"example.com",
|
|
"www.google.com",
|
|
"oss-cn-hangzhou.aliyuncs.com",
|
|
"123.45.67.89",
|
|
],
|
|
)
|
|
def test_normal_hostnames_allowed(self, hostname):
|
|
check_internal_hostname("example.com") # 不抛异常即通过
|
|
|
|
|
|
# ── 可信域名匹配 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIsTrustedDomain:
|
|
def test_empty_trusted_always_true(self):
|
|
assert is_trusted_domain("anything.com", set()) is True
|
|
|
|
def test_exact_match(self):
|
|
trusted = {"example.com", "foo.bar"}
|
|
assert is_trusted_domain("example.com", trusted) is True
|
|
assert is_trusted_domain("foo.bar", trusted) is True
|
|
|
|
def test_exact_no_match(self):
|
|
trusted = {"example.com"}
|
|
assert is_trusted_domain("other.com", trusted) is False
|
|
|
|
def test_subdomain_match(self):
|
|
trusted = {"example.com"}
|
|
assert is_trusted_domain("sub.example.com", trusted) is True
|
|
assert is_trusted_domain("a.b.example.com", trusted) is True
|
|
|
|
def test_subdomain_partial_no_match(self):
|
|
trusted = {"example.com"}
|
|
# fakeexample.com 不是 example.com 的子域名
|
|
assert is_trusted_domain("fakeexample.com", trusted) is False
|
|
|
|
def test_case_insensitive(self):
|
|
trusted = {"Example.COM"}
|
|
assert is_trusted_domain("example.com", trusted) is True
|
|
assert is_trusted_domain("SUB.Example.COM", trusted) is True
|
|
|
|
|
|
# ── IP SSRF 检查 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCheckSrfIp:
|
|
@pytest.mark.parametrize("ip", ["127.0.0.1", "127.1.2.3", "::1"])
|
|
def test_loopback_rejected(self, ip):
|
|
with pytest.raises(UrlSecurityError, match="回环"):
|
|
check_ssrf_ip(ip)
|
|
|
|
@pytest.mark.parametrize(
|
|
"ip",
|
|
[
|
|
"10.0.0.1",
|
|
"10.255.255.255",
|
|
"172.16.0.1",
|
|
"172.31.255.255",
|
|
"192.168.1.1",
|
|
"192.168.0.1",
|
|
"fd00::1", # IPv6 unique local
|
|
],
|
|
)
|
|
def test_private_rejected(self, ip):
|
|
with pytest.raises(UrlSecurityError, match="内网"):
|
|
check_ssrf_ip(ip)
|
|
|
|
@pytest.mark.parametrize("ip", ["169.254.1.1", "169.254.169.254", "fe80::1"])
|
|
def test_link_local_rejected(self, ip):
|
|
with pytest.raises(UrlSecurityError, match="链路本地"):
|
|
check_ssrf_ip(ip)
|
|
|
|
@pytest.mark.parametrize("ip", ["224.0.0.1", "239.255.255.255", "ff00::1"])
|
|
def test_multicast_rejected(self, ip):
|
|
with pytest.raises(UrlSecurityError, match="组播"):
|
|
check_ssrf_ip(ip)
|
|
|
|
@pytest.mark.parametrize("ip", ["0.0.0.0", "::"])
|
|
def test_unspecified_rejected(self, ip):
|
|
with pytest.raises(UrlSecurityError, match="未指定"):
|
|
check_ssrf_ip(ip)
|
|
|
|
def test_reserved_rejected(self):
|
|
with pytest.raises(UrlSecurityError):
|
|
check_ssrf_ip("240.0.0.1") # 保留地址段
|
|
|
|
@pytest.mark.parametrize(
|
|
"ip",
|
|
[
|
|
"8.8.8.8",
|
|
"1.1.1.1",
|
|
"223.5.5.5",
|
|
"2001:4860:4860::8888",
|
|
],
|
|
)
|
|
def test_public_ip_allowed(self, ip):
|
|
check_ssrf_ip(ip) # 不抛异常即通过
|
|
|
|
def test_invalid_ip_raises_value_error(self):
|
|
with pytest.raises(ValueError):
|
|
check_ssrf_ip("not-an-ip")
|
|
|
|
|
|
# ── IP 地址判断 ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIsIpAddress:
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
[
|
|
"127.0.0.1",
|
|
"8.8.8.8",
|
|
"192.168.1.1",
|
|
"::1",
|
|
"2001:db8::1",
|
|
"fe80::1",
|
|
],
|
|
)
|
|
def test_ip_addresses(self, host):
|
|
assert is_ip_address(host) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
[
|
|
"example.com",
|
|
"www.google.com",
|
|
"localhost",
|
|
"not-an-ip",
|
|
"",
|
|
],
|
|
)
|
|
def test_not_ip_addresses(self, host):
|
|
assert is_ip_address(host) is False
|
|
|
|
|
|
# ── URL 基础校验 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestValidateUrlBasic:
|
|
def test_normal_http_url_passes(self):
|
|
result = validate_url_basic("http://example.com/file.txt")
|
|
assert result == "http://example.com/file.txt"
|
|
|
|
def test_normal_https_url_passes(self):
|
|
result = validate_url_basic("https://www.example.com/path?q=1")
|
|
assert result == "https://www.example.com/path?q=1"
|
|
|
|
def test_standard_port_80_passes(self):
|
|
validate_url_basic("http://example.com:80/file")
|
|
|
|
def test_standard_port_443_passes(self):
|
|
validate_url_basic("https://example.com:443/file")
|
|
|
|
def test_empty_url_rejected(self):
|
|
with pytest.raises(UrlSecurityError, match="URL 为空"):
|
|
validate_url_basic("")
|
|
|
|
def test_none_url_rejected(self):
|
|
with pytest.raises(UrlSecurityError, match="URL 为空"):
|
|
validate_url_basic(None) # type: ignore
|
|
|
|
def test_too_long_url_rejected(self):
|
|
long_url = "https://example.com/" + "a" * 2100
|
|
with pytest.raises(UrlSecurityError, match="URL 过长"):
|
|
validate_url_basic(long_url)
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"ftp://example.com/file",
|
|
"file:///etc/passwd",
|
|
"javascript:alert(1)",
|
|
"data:text/html,<h1>hi</h1>",
|
|
],
|
|
)
|
|
def test_bad_scheme_rejected(self, url):
|
|
with pytest.raises(UrlSecurityError, match="不允许的 URL scheme"):
|
|
validate_url_basic(url)
|
|
|
|
def test_missing_hostname_rejected(self):
|
|
with pytest.raises(UrlSecurityError, match="URL 缺少主机名"):
|
|
validate_url_basic("http:///path")
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://localhost/test",
|
|
"http://metadata/test",
|
|
"http://foo.local/test",
|
|
],
|
|
)
|
|
def test_internal_hostname_rejected(self, url):
|
|
with pytest.raises(UrlSecurityError):
|
|
validate_url_basic(url)
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://example.com:8080/file",
|
|
"http://example.com:22/file",
|
|
"http://example.com:3306/file",
|
|
],
|
|
)
|
|
def test_non_standard_port_rejected(self, url):
|
|
with pytest.raises(UrlSecurityError, match="不允许的端口"):
|
|
validate_url_basic(url)
|
|
|
|
def test_direct_ip_rejected_by_default(self):
|
|
with pytest.raises(UrlSecurityError, match="禁止直接 IP 访问"):
|
|
validate_url_basic("http://8.8.8.8/file")
|
|
|
|
def test_direct_ip_allowed_with_flag_public(self):
|
|
result = validate_url_basic("http://8.8.8.8/file", allow_direct_ip=True)
|
|
assert result == "http://8.8.8.8/file"
|
|
|
|
def test_direct_ip_allowed_flag_but_private_still_rejected(self):
|
|
with pytest.raises(UrlSecurityError, match="内网地址"):
|
|
validate_url_basic("http://10.0.0.1/file", allow_direct_ip=True)
|
|
|
|
def test_direct_ip_loopback_rejected(self):
|
|
with pytest.raises(UrlSecurityError):
|
|
validate_url_basic("http://127.0.0.1/test", allow_direct_ip=True)
|
|
|
|
def test_trusted_domains_whitelist_pass(self):
|
|
trusted = {"example.com"}
|
|
result = validate_url_basic("https://example.com/file", trusted_domains=trusted)
|
|
assert result == "https://example.com/file"
|
|
|
|
def test_trusted_domains_subdomain_pass(self):
|
|
trusted = {"example.com"}
|
|
result = validate_url_basic("https://cdn.example.com/file", trusted_domains=trusted)
|
|
assert result == "https://cdn.example.com/file"
|
|
|
|
def test_trusted_domains_not_in_list_rejected(self):
|
|
trusted = {"example.com"}
|
|
with pytest.raises(UrlSecurityError, match="不在可信白名单"):
|
|
validate_url_basic("https://other.com/file", trusted_domains=trusted)
|
|
|
|
def test_case_insensitive_scheme(self):
|
|
# 大写 HTTP 也应该通过(我们用 .lower() 检查)
|
|
result = validate_url_basic("HTTP://example.com/file")
|
|
assert "HTTP://example.com/file" == result
|
|
|
|
|
|
# ── is_url_basic_safe 便捷函数 ──────────────────────────────────────────────
|
|
|
|
|
|
class TestIsUrlBasicSafe:
|
|
def test_safe_url_returns_true(self):
|
|
assert is_url_basic_safe("https://example.com/file") is True
|
|
|
|
def test_unsafe_url_returns_false(self):
|
|
assert is_url_basic_safe("http://localhost/test") is False
|
|
|
|
def test_empty_returns_false(self):
|
|
assert is_url_basic_safe("") is False
|
|
|
|
def test_with_trusted_domains(self):
|
|
trusted = {"allowed.com"}
|
|
assert is_url_basic_safe("https://allowed.com/x", trusted_domains=trusted) is True
|
|
assert is_url_basic_safe("https://other.com/x", trusted_domains=trusted) is False
|
|
|
|
|
|
# ── 魔数校验 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestValidateMagicNumber:
|
|
def test_mp3_id3_header(self):
|
|
data = b"ID3" + b"\x00" * 100
|
|
validate_magic_number(data, {"audio/mpeg"}) # 不抛异常
|
|
|
|
def test_mp3_frame_sync(self):
|
|
data = b"\xff\xfb" + b"\x00" * 100
|
|
validate_magic_number(data, {"audio/mpeg"})
|
|
|
|
def test_wav_header(self):
|
|
data = b"RIFF" + b"\x00" * 4 + b"WAVE" + b"\x00" * 100
|
|
validate_magic_number(data, {"audio/wav"})
|
|
|
|
def test_png_header(self):
|
|
data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
|
validate_magic_number(data, {"image/png"})
|
|
|
|
def test_jpeg_header(self):
|
|
data = b"\xff\xd8\xff" + b"\x00" * 100
|
|
validate_magic_number(data, {"image/jpeg"})
|
|
|
|
def test_gif87a_header(self):
|
|
data = b"GIF87a" + b"\x00" * 100
|
|
validate_magic_number(data, {"image/gif"})
|
|
|
|
def test_gif89a_header(self):
|
|
data = b"GIF89a" + b"\x00" * 100
|
|
validate_magic_number(data, {"image/gif"})
|
|
|
|
def test_mp4_ftyp_header(self):
|
|
data = b"\x00\x00\x00\x20ftypisom" + b"\x00" * 100
|
|
validate_magic_number(data, {"video/mp4"})
|
|
|
|
def test_ogg_header(self):
|
|
data = b"OggS" + b"\x00" * 100
|
|
validate_magic_number(data, {"audio/ogg"})
|
|
|
|
def test_flac_header(self):
|
|
data = b"fLaC" + b"\x00" * 100
|
|
validate_magic_number(data, {"audio/flac"})
|
|
|
|
def test_webp_header(self):
|
|
data = b"RIFF" + b"\x00" * 4 + b"WEBP" + b"\x00" * 100
|
|
validate_magic_number(data, {"image/webp"})
|
|
|
|
def test_bmp_header(self):
|
|
data = b"BM" + b"\x00" * 100
|
|
validate_magic_number(data, {"image/bmp"})
|
|
|
|
def test_empty_file_rejected(self):
|
|
with pytest.raises(UrlSecurityError, match="文件为空"):
|
|
validate_magic_number(b"", {"image/png"})
|
|
|
|
def test_mismatched_magic_rejected(self):
|
|
data = b"NOTAPNG" + b"\x00" * 100
|
|
with pytest.raises(UrlSecurityError, match="魔数与允许的 MIME 类型不匹配"):
|
|
validate_magic_number(data, {"image/png"})
|
|
|
|
def test_multiple_allowed_types_one_matches(self):
|
|
data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
|
# 多个允许类型,只要有一个匹配就通过
|
|
validate_magic_number(data, {"image/png", "image/jpeg", "image/gif"})
|
|
|
|
def test_no_known_magic_skips_validation(self):
|
|
# 自定义 MIME 类型没有已知魔数,跳过校验不阻断
|
|
validate_magic_number(b"random data", {"application/x-custom"})
|
|
|
|
def test_too_short_header_no_match(self):
|
|
# 文件头太短,无法匹配需要 8 字节偏移的格式
|
|
data = b"RIFF" # 只有 4 字节,不够 offset 8 的 WAVE 匹配
|
|
with pytest.raises(UrlSecurityError, match="魔数"):
|
|
validate_magic_number(data, {"audio/wav"})
|
|
|
|
def test_error_message_contains_mime_and_header(self):
|
|
with pytest.raises(UrlSecurityError) as exc_info:
|
|
validate_magic_number(b"XXXXYYY", {"image/png"})
|
|
msg = str(exc_info.value)
|
|
assert "image/png" in msg
|
|
assert "文件头前16字节" in msg
|