Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased

### Fixed
- The dashboard's per-request metadata (the `recent_requests` / `request_logs`
tail and the `config` block with upstream URLs) is gated to loopback callers
via `_request_is_loopback`. When Headroom runs in a bridge-network container
(Docker/podman, or Apple Containerization / mocker), a browser on the host
reaches the proxy through the container gateway, so `request.client.host` is
the gateway IP rather than `127.0.0.1` — the sensitive block was stripped and
the "Recent Requests" table rendered empty even though the operator is local.
A peer inside an operator-configured trusted-gateway CIDR
(`HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS`, already used to sanitize
`X-Forwarded-*`) is now treated as loopback-equivalent, while the loopback
`Host`-header gate is retained as the DNS-rebinding defence. Opt-in and empty
by default, so there is no behavior change unless the gateway CIDR is
allow-listed.
- Non-finite values (`NaN`, `Infinity`) in `proxy_savings.json` or in upstream
cost/token metadata no longer crash the proxy or corrupt the savings
dashboard. `SavingsTracker`'s numeric coercion caught only `TypeError` and
Expand Down
24 changes: 23 additions & 1 deletion headroom/proxy/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1934,7 +1934,29 @@ def _request_is_loopback(request: Request) -> bool:
host_header = request.headers.get("host")
except AttributeError:
host_header = None
return is_loopback_host(client_host) and is_loopback_host_header(host_header)

# The Host-header gate is the DNS-rebinding defence and always applies.
if not is_loopback_host_header(host_header):
return False

# Genuine loopback peer (native run, or curl inside the container).
if is_loopback_host(client_host):
return True

# Containerized dashboards: when Headroom runs in a bridge-network
# container, a browser on the host reaches it via the container's
# gateway, so ``request.client.host`` is the gateway IP, not 127.0.0.1
# — and the per-request logs / upstream URLs get stripped even though
# the operator is local. Treat a peer inside an operator-configured
# trusted-gateway CIDR as loopback-equivalent. Opt-in and empty by
# default (HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS), so this is a no-op
# unless the operator explicitly allow-lists their container gateway.
from headroom.proxy.forwarded_headers import (
load_trusted_gateway_cidrs,
peer_is_trusted_gateway,
)

return peer_is_trusted_gateway(client_host, load_trusted_gateway_cidrs())


def _is_known_websocket_callback_failure(context: dict[str, Any]) -> bool:
Expand Down
35 changes: 35 additions & 0 deletions tests/test_proxy_loopback_gating.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,38 @@ def test_stats_per_request_metadata_is_loopback_only() -> None:
local = _client(loopback=True).get("/stats").json()
assert "recent_requests" in local
assert "config" in local


def test_stats_metadata_served_to_trusted_gateway_peer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Containerized dashboards: a browser on the host reaches a bridge-network
container via the gateway IP, so the peer isn't 127.0.0.1 and per-request
metadata gets stripped. When the operator allow-lists the gateway CIDR via
HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS, the peer is treated as
loopback-equivalent and the metadata is served again."""
gateway_ip = "172.18.0.1" # typical docker/mocker bridge gateway
app = _make_app()

def _gateway_client() -> TestClient:
# Loopback Host header (the operator browses http://127.0.0.1:8787) but
# the peer IP is the container gateway, not loopback.
return TestClient(app, base_url="http://127.0.0.1", client=(gateway_ip, 54321))

# Without the allow-list, the gateway peer is untrusted → metadata stripped.
monkeypatch.delenv("HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS", raising=False)
stripped = _gateway_client().get("/stats").json()
assert "recent_requests" not in stripped
assert "config" not in stripped

# Allow-list the gateway CIDR → peer trusted → metadata served.
monkeypatch.setenv("HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS", "172.18.0.0/16")
served = _gateway_client().get("/stats").json()
assert "recent_requests" in served
assert "config" in served

# DNS-rebinding defence still applies even for a trusted gateway peer: a
# non-loopback Host header must be rejected.
rebind = TestClient(app, base_url="http://attacker.example", client=(gateway_ip, 54321))
payload = rebind.get("/stats").json()
assert "recent_requests" not in payload
Loading