diff --git a/CHANGELOG.md b/CHANGELOG.md index ece1d2d7e..4dcb29edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index 6a50ba01f..60ab7ac3c 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -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: diff --git a/tests/test_proxy_loopback_gating.py b/tests/test_proxy_loopback_gating.py index 8386ed93d..73b0fb73b 100644 --- a/tests/test_proxy_loopback_gating.py +++ b/tests/test_proxy_loopback_gating.py @@ -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