"""SSRF 防护模块单元测试。""" import ipaddress import pytest from unittest.mock import patch, AsyncMock from yuxi.utils.net_security import ( SSRFPolicy, SSRFValidator, SSRFBlockedError, _normalize_hostname, _is_blocked_hostname, _matches_domain_pattern, _is_allowed_by_domain_whitelist, _looks_like_ipv4_literal, _check_ip_literal, ) class TestNormalizeHostname: def test_lowercase(self): assert _normalize_hostname("Example.COM") == "example.com" def test_strip_trailing_dot(self): assert _normalize_hostname("example.com.") == "example.com" def test_strip_ipv6_brackets(self): assert _normalize_hostname("[::1]") == "::1" def test_empty(self): assert _normalize_hostname("") == "" def test_whitespace(self): assert _normalize_hostname(" example.com ") == "example.com" class TestBlockedHostname: def test_localhost(self): assert _is_blocked_hostname("localhost", SSRFPolicy()) def test_localhost_localdomain(self): assert _is_blocked_hostname("localhost.localdomain", SSRFPolicy()) def test_metadata_google_internal(self): assert _is_blocked_hostname("metadata.google.internal", SSRFPolicy()) def test_dot_localhost_suffix(self): assert _is_blocked_hostname("sub.localhost", SSRFPolicy()) def test_dot_local_suffix(self): assert _is_blocked_hostname("printer.local", SSRFPolicy()) def test_dot_internal_suffix(self): assert _is_blocked_hostname("service.internal", SSRFPolicy()) def test_normal_hostname_not_blocked(self): assert not _is_blocked_hostname("example.com", SSRFPolicy()) def test_empty_hostname_blocked(self): assert _is_blocked_hostname("", SSRFPolicy()) # fail-closed def test_case_insensitive(self): assert _is_blocked_hostname("Localhost", SSRFPolicy()) assert _is_blocked_hostname("SERVICE.INTERNAL", SSRFPolicy()) class TestDomainPattern: def test_exact_match(self): assert _matches_domain_pattern("api.example.com", "api.example.com") def test_subdomain_match(self): assert _matches_domain_pattern("sub.api.example.com", "api.example.com") def test_wildcard_match_subdomain(self): assert _matches_domain_pattern("sub.example.com", "*.example.com") def test_wildcard_no_match_apex(self): # *.example.com 不匹配 example.com 本身 assert not _matches_domain_pattern("example.com", "*.example.com") def test_wildcard_match_deep_subdomain(self): assert _matches_domain_pattern("a.b.example.com", "*.example.com") def test_case_insensitive(self): assert _matches_domain_pattern("Sub.Example.COM", "*.example.com") class TestDomainWhitelist: def test_empty_whitelist_allows_all(self): assert _is_allowed_by_domain_whitelist("any.com", []) def test_whitelist_match(self): assert _is_allowed_by_domain_whitelist("api.example.com", ["api.example.com"]) def test_whitelist_no_match(self): assert not _is_allowed_by_domain_whitelist("other.com", ["api.example.com"]) def test_whitelist_wildcard(self): assert _is_allowed_by_domain_whitelist("sub.example.com", ["*.example.com"]) class TestIpv4LiteralDetection: def test_octal(self): assert _looks_like_ipv4_literal("0177.0.0.1") def test_hex(self): assert _looks_like_ipv4_literal("0x7f.0.0.1") def test_short_format(self): assert _looks_like_ipv4_literal("127.1") def test_decimal_only(self): assert _looks_like_ipv4_literal("2130706433") def test_normal_hostname_not_detected(self): assert not _looks_like_ipv4_literal("example.com") def test_standard_ipv4_detected(self): # 标准 IPv4 也看起来像字面量,但会被标准解析优先处理 assert _looks_like_ipv4_literal("127.0.0.1") def test_too_many_parts(self): assert not _looks_like_ipv4_literal("1.2.3.4.5") def test_empty_part(self): assert _looks_like_ipv4_literal("1..3.4") class TestIpLiteralCheck: def test_blocked_private_ipv4(self): handled, reason = _check_ip_literal("192.168.1.1", SSRFPolicy()) assert handled assert reason def test_blocked_loopback(self): handled, reason = _check_ip_literal("127.0.0.1", SSRFPolicy()) assert handled assert reason def test_blocked_cloud_metadata(self): handled, reason = _check_ip_literal("169.254.169.254", SSRFPolicy()) assert handled assert reason def test_blocked_ipv4_mapped_ipv6(self): handled, reason = _check_ip_literal("::ffff:127.0.0.1", SSRFPolicy()) assert handled assert reason def test_blocked_ipv6_loopback(self): handled, reason = _check_ip_literal("::1", SSRFPolicy()) assert handled assert reason def test_allowed_public_ipv4(self): handled, reason = _check_ip_literal("8.8.8.8", SSRFPolicy()) assert handled assert not reason def test_nonstandard_ipv4_blocked(self): handled, reason = _check_ip_literal("0177.0.0.1", SSRFPolicy()) assert handled assert reason # fail-closed def test_domain_not_handled(self): handled, reason = _check_ip_literal("example.com", SSRFPolicy()) assert not handled def test_blocked_10_network(self): handled, reason = _check_ip_literal("10.0.0.1", SSRFPolicy()) assert handled assert reason def test_blocked_172_network(self): handled, reason = _check_ip_literal("172.16.0.1", SSRFPolicy()) assert handled assert reason def test_blocked_zero_network(self): handled, reason = _check_ip_literal("0.0.0.0", SSRFPolicy()) assert handled assert reason def test_allowed_public_ipv6(self): handled, reason = _check_ip_literal("2001:4860:4860::8888", SSRFPolicy()) assert handled assert not reason class TestSSRFValidator: def setup_method(self): self.validator = SSRFValidator() @pytest.mark.parametrize("url", [ "http://127.0.0.1:8080/admin", "http://192.168.1.1/config", "http://10.0.0.1/internal", "http://172.16.0.1/secret", "http://169.254.169.254/latest/meta-data/", "http://0.0.0.0/", "ftp://example.com/file", "http://metadata.google.internal/computeMetadata/v1/", "http://service.internal/api", "http://printer.local/ipp", ]) @pytest.mark.asyncio async def test_blocked_urls(self, url): allowed, reason = await self.validator.validate_url(url) assert not allowed, f"应阻止 {url},但通过了校验" @pytest.mark.asyncio async def test_blocked_localhost(self): allowed, reason = await self.validator.validate_url("http://localhost:5432/db") assert not allowed @pytest.mark.parametrize("url", [ "https://api.openai.com/v1/chat/completions", "https://www.baidu.com/search", "http://example.com/page", ]) @pytest.mark.asyncio async def test_allowed_urls(self, url): validator = SSRFValidator() with patch.object(validator, "_resolve_with_pin", new_callable=AsyncMock, return_value=["1.2.3.4"]): allowed, reason = await validator.validate_url(url) assert allowed, f"应允许 {url},但被阻止: {reason}" @pytest.mark.asyncio async def test_domain_whitelist(self): policy = SSRFPolicy(allowed_domains=["api.example.com", "trusted.org"]) validator = SSRFValidator(policy) with patch.object(validator, "_resolve_with_pin", new_callable=AsyncMock, return_value=["1.2.3.4"]): allowed, _ = await validator.validate_url("https://api.example.com/endpoint") assert allowed allowed, _ = await validator.validate_url("https://other.com/endpoint") assert not allowed @pytest.mark.asyncio async def test_domain_whitelist_wildcard(self): policy = SSRFPolicy(allowed_domains=["*.example.com"]) validator = SSRFValidator(policy) with patch.object(validator, "_resolve_with_pin", new_callable=AsyncMock, return_value=["1.2.3.4"]): allowed, _ = await validator.validate_url("https://sub.example.com/endpoint") assert allowed # *.example.com 不匹配 apex 域 allowed, _ = await validator.validate_url("https://example.com/endpoint") assert not allowed @pytest.mark.asyncio async def test_domain_blacklist(self): policy = SSRFPolicy(blocked_domains=["evil.com"]) validator = SSRFValidator(policy) allowed, _ = await validator.validate_url("https://evil.com/attack") assert not allowed with patch.object(validator, "_resolve_with_pin", new_callable=AsyncMock, return_value=["1.2.3.4"]): allowed, _ = await validator.validate_url("https://safe.com/page") assert allowed @pytest.mark.asyncio async def test_dns_resolution_blocked_private(self): """DNS 解析到私有 IP 时应阻止""" validator = SSRFValidator() with patch.object(validator, "_resolve_with_pin", new_callable=AsyncMock, return_value=["10.0.0.1"]): allowed, reason = await validator.validate_url("https://looks-legit.com/api") assert not allowed assert "DNS 解析到被阻止的 IP" in reason @pytest.mark.asyncio async def test_dns_resolution_fail_closed(self): """DNS 解析失败时应阻止(fail-closed)""" validator = SSRFValidator() with patch.object(validator, "_resolve_with_pin", new_callable=AsyncMock, return_value=[]): allowed, reason = await validator.validate_url("https://unresolvable.example.com/api") assert not allowed assert "DNS 解析失败" in reason @pytest.mark.asyncio async def test_ipv4_mapped_ipv6_blocked(self): """IPv4-mapped IPv6 地址应提取并校验嵌入式 IPv4""" allowed, reason = await self.validator.validate_url("http://[::ffff:127.0.0.1]/admin") assert not allowed @pytest.mark.asyncio async def test_nonstandard_ipv4_literal_blocked(self): """非标准 IPv4 字面量应被阻止(fail-closed)""" handled, reason = _check_ip_literal("0177.0.0.1", SSRFPolicy()) assert handled assert reason @pytest.mark.asyncio async def test_empty_url(self): allowed, reason = await self.validator.validate_url("") assert not allowed @pytest.mark.asyncio async def test_no_hostname(self): allowed, reason = await self.validator.validate_url("http:///path") assert not allowed @pytest.mark.asyncio async def test_invalid_scheme(self): allowed, reason = await self.validator.validate_url("ftp://example.com/file") assert not allowed assert "协议" in reason @pytest.mark.asyncio async def test_dns_pinning(self): """DNS Pinning 在 TTL 内返回缓存结果""" validator = SSRFValidator(SSRFPolicy(dns_pinned_ttl=60)) # 首次解析 with patch.object(validator, "_resolve_with_pin", new_callable=AsyncMock, return_value=["1.2.3.4"]) as mock: await validator.validate_url("https://example.com/page") assert mock.call_count == 1 class TestSSRFPolicyModel: def test_default_policy(self): policy = SSRFPolicy() assert policy.allowed_schemes == ["http", "https"] assert policy.dns_pinned_ttl == 300 assert len(policy._blocked_networks) > 0 def test_custom_policy(self): policy = SSRFPolicy( allowed_domains=["api.example.com"], blocked_domains=["evil.com"], dns_pinned_ttl=60, ) assert policy.allowed_domains == ["api.example.com"] assert policy.blocked_domains == ["evil.com"] assert policy.dns_pinned_ttl == 60 def test_blocked_networks_initialized(self): policy = SSRFPolicy() assert ipaddress.ip_network("10.0.0.0/8") in policy._blocked_networks assert ipaddress.ip_network("127.0.0.0/8") in policy._blocked_networks assert ipaddress.ip_network("::1/128") in policy._blocked_networks def test_blocked_networks_includes_cloud_metadata(self): policy = SSRFPolicy() # 验证 169.254.169.254 在某个阻止网络内 assert any(ipaddress.ip_address("169.254.169.254") in net for net in policy._blocked_networks) class TestSSRFBlockedError: def test_error_message(self): error = SSRFBlockedError("blocked", hostname="evil.com") assert str(error) == "blocked" assert error.hostname == "evil.com"