"""SSRF hardening regression tests. Covers cases the original guard missed: IPv4-mapped IPv6, CGNAT, trailing-dot hostnames, IPv6 zone identifiers, and the safe-host repr used in error messages. """ from __future__ import annotations import pytest from notify_bridge_core.notifications.ssrf import ( UnsafeURLError, PinnedResolver, avalidate_outbound_url_full, validate_outbound_url, ) class TestBlockedRanges: @pytest.mark.parametrize( "url", [ "http://[::ffff:127.0.0.1]/", # IPv4-mapped IPv6 → loopback "http://[::ffff:10.0.0.1]/", # IPv4-mapped IPv6 → RFC1918 "http://100.64.0.1/", # CGNAT (RFC 6598) "http://0.0.0.0/", # unspecified ], ) def test_rejects_extra_ranges(self, url: str) -> None: with pytest.raises(UnsafeURLError): validate_outbound_url(url) class TestHostnameNormalization: def test_strips_trailing_dot(self) -> None: # ``localhost.`` should normalize to ``localhost`` and still resolve # to the loopback address — and be blocked. with pytest.raises(UnsafeURLError): validate_outbound_url("http://localhost./") def test_rejects_bad_scheme_uppercase(self) -> None: with pytest.raises(UnsafeURLError): validate_outbound_url("FILE:///etc/passwd") class TestErrorMessages: def test_error_does_not_leak_long_hosts(self) -> None: with pytest.raises(UnsafeURLError) as ei: validate_outbound_url("http://" + "a" * 1024 + ".invalid/") # Truncated to 64 chars in the error string. assert len(str(ei.value)) < 256 class TestPinnedResolverSync: def test_pin_returns_pinned_ip(self) -> None: resolver = PinnedResolver({"example.com": "93.184.216.34"}) # Just exercise the dict path — full resolve runs in async tests. assert resolver._map["example.com"] == "93.184.216.34" # type: ignore[attr-defined] class TestAsyncFullValidator: @pytest.mark.asyncio async def test_returns_resolved_ip(self) -> None: # Literal IP — no DNS lookup; we still get back a ValidatedURL. result = await avalidate_outbound_url_full("http://8.8.8.8/") assert result.ip == "8.8.8.8" assert result.host == "8.8.8.8" @pytest.mark.asyncio async def test_rejects_blocked_literal(self) -> None: with pytest.raises(UnsafeURLError): await avalidate_outbound_url_full("http://[::ffff:127.0.0.1]/")