mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
cf4c9fab07
commit
0e166c60d4
2 changed files with 43 additions and 6 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue