Skip to content

Commit 19f69a0

Browse files
committed
fix(dashboard): serve per-request metadata to trusted-gateway peers
The dashboard's recent-requests table (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/mocker compose), a browser on the host reaches it through the container gateway, so request.client.host is the gateway IP, not 127.0.0.1 — include_sensitive is False and the table renders empty even though the operator is local. curl from inside the container (real 127.0.0.1 peer) confirmed the data is present; only the host-browser path was stripped. Treat a peer inside an operator-configured trusted-gateway CIDR (HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS, already used to sanitize X-Forwarded-*) as loopback-equivalent, while keeping the loopback Host-header gate as the DNS-rebinding defence. Opt-in and empty by default, so no behavior change unless the operator allow-lists their container gateway.
1 parent d8db7da commit 19f69a0

3 files changed

Lines changed: 71 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
## Unreleased
1010

1111
### Fixed
12+
- The dashboard's per-request metadata (the `recent_requests` / `request_logs`
13+
tail and the `config` block with upstream URLs) is gated to loopback callers
14+
via `_request_is_loopback`. When Headroom runs in a bridge-network container
15+
(Docker/podman, or Apple Containerization / mocker), a browser on the host
16+
reaches the proxy through the container gateway, so `request.client.host` is
17+
the gateway IP rather than `127.0.0.1` — the sensitive block was stripped and
18+
the "Recent Requests" table rendered empty even though the operator is local.
19+
A peer inside an operator-configured trusted-gateway CIDR
20+
(`HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS`, already used to sanitize
21+
`X-Forwarded-*`) is now treated as loopback-equivalent, while the loopback
22+
`Host`-header gate is retained as the DNS-rebinding defence. Opt-in and empty
23+
by default, so there is no behavior change unless the gateway CIDR is
24+
allow-listed.
1225
- `headroom learn` now honors `CLAUDE_CONFIG_DIR`. It resolved the Claude
1326
config directory as `~/.claude` and wrote global memory to
1427
`~/.claude/CLAUDE.md`, so users who relocate their Claude config via that

headroom/proxy/server.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1919,7 +1919,29 @@ def _request_is_loopback(request: Request) -> bool:
19191919
host_header = request.headers.get("host")
19201920
except AttributeError:
19211921
host_header = None
1922-
return is_loopback_host(client_host) and is_loopback_host_header(host_header)
1922+
1923+
# The Host-header gate is the DNS-rebinding defence and always applies.
1924+
if not is_loopback_host_header(host_header):
1925+
return False
1926+
1927+
# Genuine loopback peer (native run, or curl inside the container).
1928+
if is_loopback_host(client_host):
1929+
return True
1930+
1931+
# Containerized dashboards: when Headroom runs in a bridge-network
1932+
# container, a browser on the host reaches it via the container's
1933+
# gateway, so ``request.client.host`` is the gateway IP, not 127.0.0.1
1934+
# — and the per-request logs / upstream URLs get stripped even though
1935+
# the operator is local. Treat a peer inside an operator-configured
1936+
# trusted-gateway CIDR as loopback-equivalent. Opt-in and empty by
1937+
# default (HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS), so this is a no-op
1938+
# unless the operator explicitly allow-lists their container gateway.
1939+
from headroom.proxy.forwarded_headers import (
1940+
load_trusted_gateway_cidrs,
1941+
peer_is_trusted_gateway,
1942+
)
1943+
1944+
return peer_is_trusted_gateway(client_host, load_trusted_gateway_cidrs())
19231945

19241946

19251947
def create_app(config: ProxyConfig | None = None) -> FastAPI:

tests/test_proxy_loopback_gating.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,38 @@ def test_stats_per_request_metadata_is_loopback_only() -> None:
150150
local = _client(loopback=True).get("/stats").json()
151151
assert "recent_requests" in local
152152
assert "config" in local
153+
154+
155+
def test_stats_metadata_served_to_trusted_gateway_peer(
156+
monkeypatch: pytest.MonkeyPatch,
157+
) -> None:
158+
"""Containerized dashboards: a browser on the host reaches a bridge-network
159+
container via the gateway IP, so the peer isn't 127.0.0.1 and per-request
160+
metadata gets stripped. When the operator allow-lists the gateway CIDR via
161+
HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS, the peer is treated as
162+
loopback-equivalent and the metadata is served again."""
163+
gateway_ip = "172.18.0.1" # typical docker/mocker bridge gateway
164+
app = _make_app()
165+
166+
def _gateway_client() -> TestClient:
167+
# Loopback Host header (the operator browses http://127.0.0.1:8787) but
168+
# the peer IP is the container gateway, not loopback.
169+
return TestClient(app, base_url="http://127.0.0.1", client=(gateway_ip, 54321))
170+
171+
# Without the allow-list, the gateway peer is untrusted → metadata stripped.
172+
monkeypatch.delenv("HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS", raising=False)
173+
stripped = _gateway_client().get("/stats").json()
174+
assert "recent_requests" not in stripped
175+
assert "config" not in stripped
176+
177+
# Allow-list the gateway CIDR → peer trusted → metadata served.
178+
monkeypatch.setenv("HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS", "172.18.0.0/16")
179+
served = _gateway_client().get("/stats").json()
180+
assert "recent_requests" in served
181+
assert "config" in served
182+
183+
# DNS-rebinding defence still applies even for a trusted gateway peer: a
184+
# non-loopback Host header must be rejected.
185+
rebind = TestClient(app, base_url="http://attacker.example", client=(gateway_ip, 54321))
186+
payload = rebind.get("/stats").json()
187+
assert "recent_requests" not in payload

0 commit comments

Comments
 (0)