Commit graph

7 commits

Author SHA1 Message Date
@aaronjmars
b4b50253f1
fix(security): block DNS-rebinding on /debug/* and /stats/reset via Host-header allowlist (#605)
## Summary

`require_loopback` in `headroom/proxy/loopback_guard.py` guards
`/debug/tasks`, `/debug/ws-sessions`, `/debug/warmup`, and
`/stats/reset` by checking `request.client.host` only. A malicious
website can use DNS rebinding to make a victim's browser send requests
to `127.0.0.1` while the page origin (and inbound `Host:` header) still
reads `attacker.com`. The IP check passes — the browser IS on loopback —
and `app.add_middleware(CORSMiddleware, allow_origins=["*"], ...)`
(`headroom/proxy/server.py:1678`) lets the attacker's JS read the
response. This PR adds the canonical second gate: the `Host:` header
must also name loopback (`127.0.0.1[:port]`, `[::1][:port]`, or
`localhost[:port]`).

## Impact

While the proxy binds to `127.0.0.1` by default
(`headroom/proxy/server.py:2956`), any browser the user opens can be
used by an unrelated tab to reach the proxy via DNS rebinding.
Concretely:

- `GET /debug/tasks` → leaks asyncio task names, coroutine qualnames,
and (with `?stack=true`) stack depths — reveals which coroutines are
mid-flight and which codepaths are warm. Useful for fingerprinting +
targeting follow-on attacks.
- `GET /debug/ws-sessions` → leaks live WebSocket session metadata
including `upstream_url` per session.
- `GET /debug/warmup` → leaks warmup registry shape (lower risk).
- `POST /stats/reset` → resets in-memory proxy stats (state mutation;
minor DoS of dashboards/observability).

The fix path matches the OWASP DNS-rebinding mitigation and Starlette's
`TrustedHostMiddleware` posture.

## Location

- `headroom/proxy/loopback_guard.py:73` — guarded the IP check only, no
`Host:` validation
- `headroom/proxy/server.py:1819-1845`, `:2312` — endpoints reachable
via DNS rebinding under the old gate

## Fix

`require_loopback` now runs two gates: (1) the existing
`request.client.host` loopback IP check, and (2) a new `Host:` header
allowlist via `is_loopback_host_header(...)`. The header helper accepts
`127.0.0.1[:port]`, `[::1][:port]`, `localhost[:port]`, and IPv6-mapped
IPv4 (`::ffff:127.0.0.1`) — strips brackets and ports, then delegates to
the existing `is_loopback_host` logic. Cross-origin browser fetches
always carry the attacker's hostname in `Host:`, so rebinding requests
now 404 alongside any other external attempt.

Same 404 (not 403) semantics, so debug endpoints remain invisible to
external scanners. The new helper is in `__all__` for explicit re-use.

The existing manual-`Request`-stub unit test path (no `.headers`
attribute) is preserved by an `if headers is None: return` fallback so
older callers that pass a bare stub still work.

Test fixtures in `tests/test_proxy_debug_endpoints.py` were updated to
pin `base_url="http://127.0.0.1"` so the loopback-`Host:` invariant
holds in the green-path tests, plus a new `app_and_rebinding_client`
fixture and `test_debug_endpoints_block_dns_rebinding` exercising the
gate at the HTTP level. Six new unit tests cover
`is_loopback_host_header` (canonical accept, external reject, malformed
reject, and rebinding signature). Net: +219 lines, -4 lines, in two
files.

**Compatibility note for operators:** a deployment that fronts the proxy
behind a reverse proxy with a non-loopback Host (e.g. `headroom.local`
mapped to `127.0.0.1`) and still wants `/debug/*` exposed would need to
either point that reverse proxy at the upstream API endpoints only, or
extend `is_loopback_host_header` with an env-var allowlist in a
follow-up. The default `HEADROOM_HOST=127.0.0.1` path is unaffected.

## Detected by

Aeon (manual review — no scanner rule for this; pattern matches the
threat-model-claims + local-HTTP-server axes from
[`skills/vuln-scanner`](https://github.com/aaronjmars/aeon-aaron/blob/main/skills/vuln-scanner/SKILL.md),
priors that have surfaced 10+ similar finds across 5 languages over the
last 6 weeks).

- Severity: medium
- CWE-350 (Reliance on Reverse DNS Resolution for a Security-Critical
Action)
- CWE-352 (Cross-Site Request Forgery) — adjacent
- Class match: DNS-rebinding-via-loopback-bind (recurring axis in the
tracker)

## Verification

- `python3 -m py_compile headroom/proxy/loopback_guard.py` — parses.
- The new unit tests in `tests/test_proxy_debug_endpoints.py` cover:
canonical loopback Host headers (accept), external Host headers
(reject), malformed/empty (reject), an HTTP-level DNS-rebinding
simulation (`app_and_rebinding_client` fixture with
`base_url="http://attacker.com"` + loopback client tuple), and the
unchanged IP-only stub path.
- Other test files that use `require_loopback`
(`test_proxy_openai_responses_integration.py`,
`test_proxy_openai_responses_bypass.py`,
`test_proxy_dashboard_stats_cache.py`) all override it via
`app.dependency_overrides[require_loopback] = lambda: None`, so they are
not affected by the new gate.
- I was unable to run `pytest` locally in this sandbox; please confirm
the new fixtures slot in cleanly when the CI matrix runs.

---
Filed by [Aeon](https://github.com/aaronjmars/aeon-aaron).

---------

Co-authored-by: aeonframework <noreply@anthropic.com>
2026-06-11 12:58:33 -05:00
Adryan Eka Vandra
4e9348dec4
fix: add anthropic pre-upstream timeouts 2026-04-21 09:48:32 +07:00
Adryan Eka Vandra
b71b659e1b
fix(ci): restore repro harness test in dev installs
Add websockets to the dev extra so the repro harness smoke test can import
its websocket client dependency in the CI test matrix. Also apply ruff
formatting to the files the formatter check was rejecting so the 3.12 lint
job passes.
2026-04-20 22:12:01 +07:00
Adryan Eka Vandra
8bbf3afda0
perf(proxy): gate /debug/tasks stack-depth computation behind query param
Task.get_stack(limit=32) walks coroutine frames synchronously and
measurably stalls the event loop when called for 50+ relay tasks during
a reconnect storm. The /debug/tasks snapshot does not need this by
default — the perf-cheap fields (name, coro_qualname, age, done) are
enough for the common case of "which tasks are alive".

- collect_tasks: add with_stack_depth=False kwarg; default leaves
  stack_depth=None per entry.
- /debug/tasks: read ?stack=true query parameter and forward it. Default
  response is cheap; opt-in explicitly when human-debugging one snapshot.
- Test: verify default entries have stack_depth=None and that
  ?stack=true produces at least one integer depth.
2026-04-20 22:09:24 +07:00
Adryan Eka Vandra
55f59fad77
chore(proxy): announce pre-upstream concurrency; clean dead RequestLog fields and debug helpers
- Log the resolved Anthropic pre-upstream concurrency at startup so
  operators can correlate pre_upstream_wait_ms lines with the configured
  cap. Distinguishes auto-detected vs. explicit vs. unbounded.
- Remove the stage_timings, session_id, and stage_timings_path fields
  from RequestLog. They were declared optional but never populated at
  any of the three RequestLog construction sites — stage timings flow
  exclusively through emit_stage_timings_log (structured log line) and
  Prometheus histograms. Keeping unpopulated fields misled readers.
  Option (A) from the review.
- Inline the one-liner collect_ws_sessions / collect_warmup pass-throughs
  at their two callers in server.py; keep collect_tasks since it has
  real logic. Unit tests were rewritten against the registry's own
  snapshot()/to_dict() serializers.
2026-04-20 22:09:23 +07:00
Adryan Eka Vandra
0e166c60d4
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.
2026-04-20 22:08:09 +07:00
Adryan Eka Vandra
a4010aaa1b
feat(proxy): add loopback-only debug introspection endpoints
Unit 5 of the Codex-proxy resilience plan. Three always-on
/debug/* endpoints make "what is this process doing right now?"
a single curl away:

- GET /debug/tasks serializes asyncio.all_tasks() to name,
  coro_qualname, stack_depth, done, and age_seconds (derived from
  the WS session registry for Codex relay tasks, null otherwise).
  Sorted by age desc. No frame locals, no request bodies, no
  coroutine args.
- GET /debug/ws-sessions returns WebSocketSessionRegistry.snapshot().
- GET /debug/warmup returns WarmupRegistry.to_dict().

A FastAPI Depends(require_loopback) dependency 404s (not 403) any
non-loopback caller so debug routes stay invisible to external
scanners. Dependency chosen over middleware because the route set
is small and explicit Depends(...) makes the guard visible to
reviewers and easy to override in tests.
2026-04-20 22:01:42 +07:00