fix(proxy): accept IPv6-mapped IPv4 loopback in debug guard

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.
This commit is contained in:
Adryan Eka Vandra 2026-04-18 01:55:49 +07:00
parent cf4c9fab07
commit 0e166c60d4
No known key found for this signature in database
GPG key ID: A46A577A26A97682
2 changed files with 43 additions and 6 deletions

View file

@ -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]

View file

@ -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"