"""Tests for ledgrab.utils.net_classify.""" import pytest from ledgrab.utils.net_classify import ( HostCategory, classify_ip, is_blocked_for_ssrf, is_local_for_http_default, is_loopback, ) @pytest.mark.parametrize( "host,expected", [ ("127.0.0.1", HostCategory.LOOPBACK), ("::1", HostCategory.LOOPBACK), ("10.0.0.5", HostCategory.PRIVATE), ("192.168.1.1", HostCategory.PRIVATE), ("172.16.0.5", HostCategory.PRIVATE), ("fd00::1", HostCategory.PRIVATE), # ULA ("169.254.1.1", HostCategory.LINK_LOCAL), ("fe80::1", HostCategory.LINK_LOCAL), ("0.0.0.0", HostCategory.UNSPECIFIED), ("::", HostCategory.UNSPECIFIED), ("224.0.0.1", HostCategory.MULTICAST), ("ff00::1", HostCategory.MULTICAST), # ``240.0.0.0/4`` (class E) is labelled both ``is_private`` and # ``is_reserved`` by Python; we keep PRIVATE first which is the # stricter SSRF policy. ("240.0.0.1", HostCategory.PRIVATE), ("8.8.8.8", HostCategory.PUBLIC), ("1.1.1.1", HostCategory.PUBLIC), ("2606:4700:4700::1111", HostCategory.PUBLIC), ("not-an-ip", HostCategory.UNPARSEABLE), ("", HostCategory.UNPARSEABLE), ("example.com", HostCategory.UNPARSEABLE), ], ) def test_classify_ip(host: str, expected: HostCategory) -> None: assert classify_ip(host) is expected # --------------------------------------------------------------------------- # SSRF block list — must include EVERY non-public category, including # unparseable inputs. Regression guard: if anyone narrows this set we lose # SSRF protection. # --------------------------------------------------------------------------- @pytest.mark.parametrize( "host", [ "127.0.0.1", "::1", "10.0.0.5", "192.168.1.1", "172.16.0.5", "fd00::1", "169.254.1.1", "fe80::1", "0.0.0.0", "::", "224.0.0.1", "ff00::1", "240.0.0.1", "not-an-ip", # unparseable → blocked "", # unparseable → blocked ], ) def test_is_blocked_for_ssrf_blocks_non_public(host: str) -> None: assert is_blocked_for_ssrf(host) is True @pytest.mark.parametrize( "host", ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"], ) def test_is_blocked_for_ssrf_allows_public(host: str) -> None: assert is_blocked_for_ssrf(host) is False # --------------------------------------------------------------------------- # LAN-default policy — narrower than SSRF: we infer ``http://`` for loopback # / private / link-local / unspecified, NOT for multicast / reserved / # unparseable (those should fall through to ``https://`` or to caller-side # heuristics like mDNS suffix matching). # --------------------------------------------------------------------------- @pytest.mark.parametrize( "host", ["127.0.0.1", "::1", "10.0.0.5", "192.168.1.1", "fe80::1", "0.0.0.0"], ) def test_is_local_for_http_default_true(host: str) -> None: assert is_local_for_http_default(host) is True @pytest.mark.parametrize( "host", ["8.8.8.8", "224.0.0.1", "not-an-ip", ""], ) def test_is_local_for_http_default_false(host: str) -> None: assert is_local_for_http_default(host) is False # --------------------------------------------------------------------------- # Loopback predicate — accepts both literals and the auth module's textual # placeholders (localhost, testclient). # --------------------------------------------------------------------------- @pytest.mark.parametrize( "host", [ "127.0.0.1", "127.0.0.5", "::1", "localhost", "LOCALHOST", "testclient", "[::1]", "fe80::1%eth0", # ← link-local with zone — must NOT match ], ) def test_is_loopback_recognises_loopback(host: str) -> None: expected = host != "fe80::1%eth0" assert is_loopback(host) is expected @pytest.mark.parametrize( "host", ["8.8.8.8", "10.0.0.5", "example.com", "", None], ) def test_is_loopback_rejects_other(host) -> None: assert is_loopback(host) is False # ─── validate_lan_host ──────────────────────────────────────────── from ledgrab.utils.net_classify import validate_lan_host # noqa: E402 @pytest.mark.parametrize( "host", [ "127.0.0.1", "10.0.0.5", "192.168.1.50", "172.16.0.10", "fe80::1", "fc00::1", "0.0.0.0", # IPv6 with brackets and zone id "[fe80::1%eth0]", # Plain hostnames pass through (mDNS / bare-label) "bulb.local", "office-strip", "controller.lan", "", # empty also passes (caller handles validation upstream) ], ) def test_validate_lan_host_accepts_local(host) -> None: """Loopback / private / link-local / hostnames must not raise.""" validate_lan_host(host) @pytest.mark.parametrize( "host", [ "1.1.1.1", # Cloudflare DNS (routable public) "8.8.8.8", # Google DNS "224.0.0.1", # multicast — not a LAN device address "2606:4700:4700::1111", # Cloudflare DNS IPv6 ], ) def test_validate_lan_host_rejects_public_or_multicast(host) -> None: """Reject genuinely-public addresses and multicast targets. Note: Python's :func:`ipaddress.ip_address(...).is_private` treats RFC6890 ranges (documentation 192.0.2/24, former class E 240/4, etc.) as private, so those are accepted as "local-ish" by our classifier -- which is the correct policy for LedGrab: those ranges aren't routable public addresses, and refusing them would be unhelpful theatre. """ with pytest.raises(ValueError, match="LedGrab is LAN-only"): validate_lan_host(host)