From 0e166c60d4e282b046a552310d703434bc481cd6 Mon Sep 17 00:00:00 2001 From: Adryan Eka Vandra Date: Sat, 18 Apr 2026 01:55:49 +0700 Subject: [PATCH] fix(proxy): accept IPv6-mapped IPv4 loopback in debug guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux dual-stack sockets (IPV6_V6ONLY=0 default), an IPv4 loopback connection arrives as ::ffff:127.0.0.1 and was 404'd by the literal set check — silent outage of /debug/* when the proxy binds to :: or 0.0.0.0. Replace the set-membership check with ipaddress.ip_address(host).is_loopback, special-casing 'localhost' (not an IP literal) and treating ValueError (malformed input) as non-loopback. The None sentinel for TestClient / UDS sockets is preserved. --- headroom/proxy/loopback_guard.py | 28 ++++++++++++++++++++++------ tests/test_proxy_debug_endpoints.py | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) 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"