diff --git a/headroom/proxy/loopback_guard.py b/headroom/proxy/loopback_guard.py index 4341fc729..296959c4d 100644 --- a/headroom/proxy/loopback_guard.py +++ b/headroom/proxy/loopback_guard.py @@ -18,6 +18,8 @@ middleware) because: would be disproportionate. """ +import ipaddress + try: from fastapi import HTTPException, Request except ImportError: # pragma: no cover - fastapi is a hard dep in practice @@ -32,21 +34,35 @@ __all__ = [ ] -# Accepted loopback hostnames. ``None`` covers ``TestClient`` which does -# not populate ``request.client`` by default; we treat that as loopback -# because it means the call did not originate from a real socket. +# Legacy canonical loopback literal set. Retained for backwards +# compatibility with callers/tests that still import it; the real check +# now goes through :func:`ipaddress.ip_address(...).is_loopback` so we +# also accept IPv6-mapped IPv4 (``::ffff:127.0.0.1``) and other valid +# loopback literals on dual-stack sockets. LOOPBACK_HOSTS: frozenset[str] = frozenset({"127.0.0.1", "::1", "localhost"}) def is_loopback_host(host: str | None) -> bool: """Return True if ``host`` represents a loopback interface. - ``None`` is treated as loopback — this covers ``TestClient`` - requests where FastAPI does not populate ``request.client``. + ``None`` is treated as loopback — this covers ``TestClient`` / + UDS-style requests where FastAPI does not populate + ``request.client``. + + ``"localhost"`` is special-cased as a string since it is not a + valid IP literal. Every other host is parsed with + :func:`ipaddress.ip_address`; this accepts IPv6-mapped IPv4 + (``::ffff:127.0.0.1``) which Linux dual-stack sockets emit by + default. Malformed input returns ``False``. """ if host is None: return True - return host in LOOPBACK_HOSTS + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False def require_loopback(request: Request) -> None: # type: ignore[valid-type] diff --git a/tests/test_proxy_debug_endpoints.py b/tests/test_proxy_debug_endpoints.py index f7b1afb18..7bd24e662 100644 --- a/tests/test_proxy_debug_endpoints.py +++ b/tests/test_proxy_debug_endpoints.py @@ -95,6 +95,27 @@ def test_is_loopback_host_rejects_external_hosts(): assert is_loopback_host("8.8.8.8") is False +def test_is_loopback_host_accepts_ipv6_mapped_ipv4_loopback(): + # On Linux dual-stack sockets with IPV6_V6ONLY=0, an IPv4 loopback + # connection arrives as ``::ffff:127.0.0.1``. The guard must treat + # this as loopback or /debug/* silently 404s when the proxy binds + # to ``::`` / ``0.0.0.0``. + assert is_loopback_host("::ffff:127.0.0.1") is True + + +def test_is_loopback_host_rejects_ipv6_mapped_external_ipv4(): + assert is_loopback_host("::ffff:10.0.0.1") is False + + +def test_is_loopback_host_rejects_non_loopback_ipv6(): + assert is_loopback_host("2001:db8::1") is False + + +def test_is_loopback_host_rejects_malformed_input(): + assert is_loopback_host("not-an-ip") is False + assert is_loopback_host("") is False + + def test_require_loopback_raises_404_for_external_client(): class _FakeClient: host = "10.0.0.1"