2026-04-17 15:42:34 +07:00
|
|
|
"""Tests for the loopback-only /debug/* introspection endpoints (Unit 5)."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
fix(proxy/debug): reconcile Kompress warmup state in /debug/warmup (#2711)
## Description
`/debug/warmup` serialized the warmup registry verbatim, so a Kompress
slot left at the startup snapshot kept reporting `{"status": "null",
"info": {"source_status": "deferred"}}` forever — even while the ONNX
model was loaded and actively compressing.
`/health` and `/readyz` already fix this: #2402 added
`_reconcile_kompress_health()`, which promotes the slot from live
runtime state. The debug route never called it, so its answer depended
on whether a health probe happened to run first. That is the half of
#2624 still reproducing on `main`.
Second defect: `WarmupSlot.mark_loaded()` only *updates* `info`, so the
startup-planted `source_status: "deferred"` survived promotion and the
slot serialized as the self-contradictory `{"status": "loaded", "info":
{"source_status": "deferred", "backend": "onnx"}}`.
Closes #2624
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/proxy/server.py`: call the existing
`_reconcile_kompress_health()` in the `/debug/warmup` route before
serializing the registry. The reconciler never instantiates a compressor
and never calls `preload()` / `ensure_background_load()` / `compress()`
— it only reads `is_ready()` / `ready_backend()` on an already resident
instance, or falls back to the module-level ONNX cache — so the endpoint
stays side-effect free and idempotent.
- `headroom/proxy/server.py`: stamp `source_status="runtime"` at both
`mark_loaded()` promotion sites in `_reconcile_kompress_health()` (the
resident-compressor path and the `_kompress_cache` fallback),
overwriting the stale startup marker.
- `tests/test_proxy_debug_endpoints.py`: three regression tests plus a
read-only compressor stub whose `preload` / `ensure_background_load`
raise, so a future change that makes the debug route trigger a load
fails loudly.
- `tests/test_proxy_health.py`: assert the promoted slot's
`info["source_status"] == "runtime"`.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ pytest tests/test_proxy_debug_endpoints.py tests/test_proxy_health.py tests/test_proxy_warmup.py -q
tests\test_proxy_debug_endpoints.py ............................. [ 52%]
tests\test_proxy_health.py ................. [ 83%]
tests\test_proxy_warmup.py ......... [100%]
============================= 55 passed in 36.56s =============================
$ ruff check headroom/proxy/server.py tests/test_proxy_debug_endpoints.py tests/test_proxy_health.py
All checks passed!
$ ruff format --check headroom/proxy/server.py tests/test_proxy_debug_endpoints.py tests/test_proxy_health.py
3 files already formatted
$ mypy headroom --ignore-missing-imports
Success: no issues found in 506 source files
```
The three new tests were confirmed to be genuine regression tests: with
the `server.py` change reverted and the tests kept, all three fail.
```text
$ git stash push -- headroom/proxy/server.py && pytest tests/test_proxy_debug_endpoints.py -q -k kompress
FAILED tests/test_proxy_debug_endpoints.py::test_debug_warmup_promotes_deferred_kompress_after_runtime_load
FAILED tests/test_proxy_debug_endpoints.py::test_debug_warmup_keeps_pending_kompress_null
FAILED tests/test_proxy_debug_endpoints.py::test_debug_warmup_never_starts_kompress_loading
====================== 3 failed, 26 deselected in 3.98s =======================
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.13.11, pytest 9.1.1, ruff 0.15.x,
mypy 1.20.2, branch based on `main` at 6d5516dc
- Exact command / steps: `pytest tests/test_proxy_debug_endpoints.py
tests/test_proxy_health.py tests/test_proxy_warmup.py -q`, then `git
stash push -- headroom/proxy/server.py` and re-run `pytest
tests/test_proxy_debug_endpoints.py -q -k kompress` to confirm the new
tests fail without the fix
- Observed result: 55 passed with the fix. Without the fix the three new
`/debug/warmup` tests fail — the slot stays `status: "null"` with
`info.source_status: "deferred"` and the stub records zero calls, i.e.
the endpoint never looked at live runtime state. With the fix the same
slot serializes as `{"status": "loaded", "info": {"source_status":
"runtime", "backend": "onnx"}}` and the stub records exactly
`["is_ready", "ready_backend"]` — no load triggered.
- Not tested: the live end-to-end proxy path (cold start, real ONNX
download, real request traffic). This machine has no `onnxruntime` /
`transformers` installed, so a real Kompress load cannot run here; the
tests substitute a stub at the same seam `_reconcile_kompress_health()`
reads. Unrelated to this change, that missing-dependency environment
also makes the pre-existing
`tests/test_kompress_preload_deferral.py::test_proxy_startup_does_not_enter_cached_kompress_native_loader`
fail locally (it reports `source_status: "unavailable"` instead of
`"deferred"`); it fails identically on unmodified `main`.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:15:01 +02:00
|
|
|
from contextlib import contextmanager
|
2026-04-17 15:42:34 +07:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
pytest.importorskip("fastapi")
|
|
|
|
|
pytest.importorskip("httpx")
|
|
|
|
|
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
from headroom.proxy.debug_introspection import (
|
|
|
|
|
collect_tasks,
|
|
|
|
|
)
|
|
|
|
|
from headroom.proxy.loopback_guard import (
|
|
|
|
|
LOOPBACK_HOSTS,
|
|
|
|
|
is_loopback_host,
|
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 13:58:33 -04:00
|
|
|
is_loopback_host_header,
|
2026-04-17 15:42:34 +07:00
|
|
|
require_loopback,
|
|
|
|
|
)
|
|
|
|
|
from headroom.proxy.server import ProxyConfig, create_app
|
|
|
|
|
from headroom.proxy.warmup import WarmupRegistry
|
|
|
|
|
from headroom.proxy.ws_session_registry import (
|
|
|
|
|
WebSocketSessionRegistry,
|
|
|
|
|
WSSessionHandle,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Shared fixtures
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
chore: release main (#2792)
:robot: I have created a release *beep* *boop*
---
<details><summary>0.35.0</summary>
##
[0.35.0](https://github.com/headroomlabs-ai/headroom/compare/v0.34.0...v0.35.0)
(2026-08-12)
### Features
* **beacon:** allowlist the routing summary key
([#2818](https://github.com/headroomlabs-ai/headroom/issues/2818))
([7940c05](https://github.com/headroomlabs-ai/headroom/commit/7940c05ebf4486c6b9d00984067ae33cedf4dddb))
* **beacon:** hourly R2 compaction, per-strategy savings, and a stack
that reports
([#2853](https://github.com/headroomlabs-ai/headroom/issues/2853))
([e0870ef](https://github.com/headroomlabs-ai/headroom/commit/e0870ef931e5ea6cc6cb52551f5d80cd9e3dc715))
* **cli,pricing:** add CLI extension seam and prompt-cache TTL pricing
([#2802](https://github.com/headroomlabs-ai/headroom/issues/2802))
([6ec3e34](https://github.com/headroomlabs-ai/headroom/commit/6ec3e3478abf058fe1460f91342bcdadf54a1ba8))
### Bug Fixes
* **anthropic:** strip first-party tool search on custom upstreams
([#2539](https://github.com/headroomlabs-ai/headroom/issues/2539))
([7f6950b](https://github.com/headroomlabs-ai/headroom/commit/7f6950be34e29304deae0fa5138b852491b092fe))
* **backends/anyllm:** convert Anthropic tools and tool_choice to OpenAI
shape
([0d6866b](https://github.com/headroomlabs-ai/headroom/commit/0d6866b91a3777475abd58cd8b63a10cd0621e7f))
* **backends/anyllm:** stream tool_use blocks and map finish_reason on
the streaming path
([e4904e2](https://github.com/headroomlabs-ai/headroom/commit/e4904e23a6ba6f5cff2488332946481172446922))
* **backends/litellm:** None-guard core token counts in OpenAI usage
block ([#2324](https://github.com/headroomlabs-ai/headroom/issues/2324))
([12f9f58](https://github.com/headroomlabs-ai/headroom/commit/12f9f58cb3dcfc67af1238424d404d8dd9bad1dd))
* **beacon:** report all-layers savings, not context-compression only
([#2796](https://github.com/headroomlabs-ai/headroom/issues/2796))
([e9a24f3](https://github.com/headroomlabs-ai/headroom/commit/e9a24f3ec1ffd278b0b3ca547a90942c40c99ec8))
* **beacon:** split session failures by status code
([#2815](https://github.com/headroomlabs-ai/headroom/issues/2815))
([2954e37](https://github.com/headroomlabs-ai/headroom/commit/2954e37048f8dcffe16e1c37b8f71afb0094a0a2))
* **cache:** bound compression cache bookkeeping
([0ae948c](https://github.com/headroomlabs-ai/headroom/commit/0ae948c1510735df39317bf0861f8a8750cdbf9d))
* **cache:** enforce Anthropic's 1h-before-5m cache_control ordering
before forwarding
([#2941](https://github.com/headroomlabs-ai/headroom/issues/2941))
([3752458](https://github.com/headroomlabs-ai/headroom/commit/3752458022f736c779f7b5a6c2d6d2ef0bc89f72))
* **cache:** mirror client cache_control positions instead of
single-marker consolidation
([def3d76](https://github.com/headroomlabs-ai/headroom/commit/def3d76e5ab4665e609b51bfba54dd6d25116925))
* **cache:** stabilize Anthropic block-growing lineages
([#2917](https://github.com/headroomlabs-ai/headroom/issues/2917))
([1a04c95](https://github.com/headroomlabs-ai/headroom/commit/1a04c957f53ef25ab1209166f425a7876913c4d3))
* **ccr:** avoid injecting tool on chat streaming
([d0c1f5b](https://github.com/headroomlabs-ai/headroom/commit/d0c1f5b8ad68c7a44ed3aaa0fe40e3a656950123))
* **ccr:** preserve exact SQLite TTL boundary
([#2669](https://github.com/headroomlabs-ai/headroom/issues/2669))
([d0a86d4](https://github.com/headroomlabs-ai/headroom/commit/d0a86d409fab377f9c642d1f3680b6ece7f97b8a))
* **ccr:** report embedded hashes from compress endpoint
([#717](https://github.com/headroomlabs-ai/headroom/issues/717))
([685ebe4](https://github.com/headroomlabs-ai/headroom/commit/685ebe457d727922ba4057515556a2d2aac0f616))
* **ccr:** resolve <<ccr:...>> markers inline when no
retrieve-tool path exists
([#2512](https://github.com/headroomlabs-ai/headroom/issues/2512))
([ce8ce83](https://github.com/headroomlabs-ai/headroom/commit/ce8ce8313f8cebf060392a62f9adaab18c0df386))
* **ccr:** tolerate null/malformed OpenAI data in response handling
([#2467](https://github.com/headroomlabs-ai/headroom/issues/2467))
([e583e08](https://github.com/headroomlabs-ai/headroom/commit/e583e082d8dee942229ac6211c742f9c9448a905))
* **ci:** publish latest from the root Docker manifest
([#2252](https://github.com/headroomlabs-ai/headroom/issues/2252))
([5568d73](https://github.com/headroomlabs-ai/headroom/commit/5568d738afb5e080d8df56e64500026996cbf025))
* **claude:** stop forcing tool search on Foundry
([#2477](https://github.com/headroomlabs-ai/headroom/issues/2477))
([7981396](https://github.com/headroomlabs-ai/headroom/commit/798139608c0fb5118eb3a7a183b8b2abe92341f1))
* **cli/update:** let install ownership win over bare /.dockerenv so
venv installs self-update
([#2830](https://github.com/headroomlabs-ai/headroom/issues/2830))
([7092b53](https://github.com/headroomlabs-ai/headroom/commit/7092b53c466bf5dbda8a1cda88403d1a4b16deb1))
* **codex:** route alpha search through the Codex backend
([#2538](https://github.com/headroomlabs-ai/headroom/issues/2538))
([a540eb2](https://github.com/headroomlabs-ai/headroom/commit/a540eb2c61b1a47e5ab8b07ea4a80fee780b6514))
* **content-router:** protect custom-tag blocks before mixed-content
section split
([d7bc1e2](https://github.com/headroomlabs-ai/headroom/commit/d7bc1e275f411788abffa2d007db14aa17fd31c5))
* **deps:** bump h2 to 4.4.1 for CVE-2026-71554
([#2839](https://github.com/headroomlabs-ai/headroom/issues/2839))
([564e0a8](https://github.com/headroomlabs-ai/headroom/commit/564e0a8d0fe440dff21a6c405c88e05698b3059f))
* **deps:** enforce audited transitive dependency floors
([#2791](https://github.com/headroomlabs-ai/headroom/issues/2791))
([64e2039](https://github.com/headroomlabs-ai/headroom/commit/64e203931b9810e5a010f063d26d154419016f86))
* **doctor:** flag `ollama launch claude` proxy bypass instead of
misdirecting
([#2566](https://github.com/headroomlabs-ai/headroom/issues/2566))
([7f24d69](https://github.com/headroomlabs-ai/headroom/commit/7f24d695eea00b9bb3265fbaa6629acf0c2ff181))
* emit SSE ping before message_start on Bedrock streaming path (issue
[#902](https://github.com/headroomlabs-ai/headroom/issues/902))
([#1080](https://github.com/headroomlabs-ai/headroom/issues/1080))
([4dab254](https://github.com/headroomlabs-ai/headroom/commit/4dab254d52914c39ffe13071848604e1771b1bd1))
* **gemini:** resolve native CCR retrieval calls
([#2253](https://github.com/headroomlabs-ai/headroom/issues/2253))
([2483f57](https://github.com/headroomlabs-ai/headroom/commit/2483f570025763cd9183a93749ea8cf38f1aeb85))
* **health:** label kompress as degraded/optional when not yet loaded
([#2865](https://github.com/headroomlabs-ai/headroom/issues/2865))
([8949371](https://github.com/headroomlabs-ai/headroom/commit/89493714d2cffdc1f81a8f417ea09891453d7009))
* **image:** decouple routing types from trained_router so importing the
compressor doesn't import torch
([#2513](https://github.com/headroomlabs-ai/headroom/issues/2513))
([#2537](https://github.com/headroomlabs-ai/headroom/issues/2537))
([d7cf981](https://github.com/headroomlabs-ai/headroom/commit/d7cf981093cf505192a3736dadd0254a120830a1))
* **install/windows:** register persistent-task from S4U hidden XML
([#2453](https://github.com/headroomlabs-ai/headroom/issues/2453))
([#2459](https://github.com/headroomlabs-ai/headroom/issues/2459))
([1edaeb8](https://github.com/headroomlabs-ai/headroom/commit/1edaeb8b76f6b872a6c810d404c944caf1a594b2))
* **install:** don't crash the PowerShell installer when $PROFILE is
unset ([#2469](https://github.com/headroomlabs-ai/headroom/issues/2469))
([fc5c4e2](https://github.com/headroomlabs-ai/headroom/commit/fc5c4e239ce32f2b90a6777772a01bdf49c66cb6))
* **install:** trust Docker bridge for dashboard metadata
([e044139](https://github.com/headroomlabs-ai/headroom/commit/e044139001680fd5198147bf373df6f00db32cc7))
* **install:** use --userns=keep-id under Podman so bind-mount writes
don't fail
([#2846](https://github.com/headroomlabs-ai/headroom/issues/2846))
([3488f8d](https://github.com/headroomlabs-ai/headroom/commit/3488f8d4b5fae4eab157e0c4031ccf712bcbcc0d))
* **learn/gemini:** stop double-counting session tokens
([#2230](https://github.com/headroomlabs-ai/headroom/issues/2230))
([29d8a5e](https://github.com/headroomlabs-ai/headroom/commit/29d8a5e563cf16dbd3a53a1571f4f352e61e1b33))
* **learn/grok:** detect a Windows absolute project path
([#2283](https://github.com/headroomlabs-ai/headroom/issues/2283))
([e240df2](https://github.com/headroomlabs-ai/headroom/commit/e240df2b698e601324b85956bd93cb304f6030ab))
* **learn:** stop classifying a successful exit code 0 as an error
([#2289](https://github.com/headroomlabs-ai/headroom/issues/2289))
([a24fe7d](https://github.com/headroomlabs-ai/headroom/commit/a24fe7dcbfe5ab30d0cef631c936e2245c12d123))
* **litellm:** add async_post_call_success_hook to HeadroomCallback
([#1322](https://github.com/headroomlabs-ai/headroom/issues/1322))
([3107994](https://github.com/headroomlabs-ai/headroom/commit/3107994aed5fd42e713d3c26f3f08121a62b980e))
* **litellm:** don't forward a caller key the target cannot accept
([#2883](https://github.com/headroomlabs-ai/headroom/issues/2883))
([2f2950a](https://github.com/headroomlabs-ai/headroom/commit/2f2950a626cebf851aac29255e7188fbb1639f5a))
* **memory:** bound the TrafficLearner pending-pattern accumulator
(memory leak)
([#2579](https://github.com/headroomlabs-ai/headroom/issues/2579))
([1f5feff](https://github.com/headroomlabs-ai/headroom/commit/1f5fefffd3e82c73bddd928cfd53334031e807bc))
* **memory:** close DirectMem0 resources
([6596182](https://github.com/headroomlabs-ai/headroom/commit/65961827cf5e90d7b4e7026feb89aac000a73ea3))
* **memory:** close MCP backend on shutdown
([4bd8ecd](https://github.com/headroomlabs-ai/headroom/commit/4bd8ecd1e31475365801791d35630f66f7393553))
* **memory:** don't crash inline memory extraction on a non-object
<memory> block
([#2470](https://github.com/headroomlabs-ai/headroom/issues/2470))
([e00c6ff](https://github.com/headroomlabs-ai/headroom/commit/e00c6ff81ce2003e04042b8f2d1bd6aa3c6e885c))
* **memory:** keep vector metadata in sync
([#2295](https://github.com/headroomlabs-ai/headroom/issues/2295))
([c471800](https://github.com/headroomlabs-ai/headroom/commit/c471800e8ee22986c308464b02a85da5575f34cc))
* **memory:** make explicit-project and user store keys
collision-resistant
([#2231](https://github.com/headroomlabs-ai/headroom/issues/2231))
([f840d5f](https://github.com/headroomlabs-ai/headroom/commit/f840d5f2fe938432e542c3f71f2218eeecd06b05))
* **memory:** skip <system-reminder> blocks when building the
retrieval query
([#2195](https://github.com/headroomlabs-ai/headroom/issues/2195))
([#2541](https://github.com/headroomlabs-ai/headroom/issues/2541))
([4e5a67a](https://github.com/headroomlabs-ai/headroom/commit/4e5a67a342be4be659b62c7863a9e72422605788))
* **memory:** sync FTS5 and vector indexes on CLI
delete/edit/prune/purge
([fd4628d](https://github.com/headroomlabs-ai/headroom/commit/fd4628d82156c65d4fa22df9513315790a6cd2fb))
* **oauth2:** make repository lint checks pass
([c85abf7](https://github.com/headroomlabs-ai/headroom/commit/c85abf7a87920012e01f0a677f6fbd98c4b08de0))
* **observability:** aggregate tool savings in OTEL
([#2936](https://github.com/headroomlabs-ai/headroom/issues/2936))
([941c25d](https://github.com/headroomlabs-ai/headroom/commit/941c25d31e6c6e0b436c307cbe212771ff76b45f))
* **onnx:** stop ONNX thread pools from spinning idle cores
([#2495](https://github.com/headroomlabs-ai/headroom/issues/2495))
([#2540](https://github.com/headroomlabs-ai/headroom/issues/2540))
([5c561bd](https://github.com/headroomlabs-ai/headroom/commit/5c561bd913ea60fad2c3c53f4b65e679e7d248d0))
* **openai:** skip Responses tool-search deferral for clients that
cannot execute it
([#2696](https://github.com/headroomlabs-ai/headroom/issues/2696))
([54ea28d](https://github.com/headroomlabs-ai/headroom/commit/54ea28d9839a0dcfa4dd0cf4210a4421f03beeff))
* **opencode:** ship the transport hook-shim so wheel installs route
Node child traffic
([702dbc5](https://github.com/headroomlabs-ai/headroom/commit/702dbc5902ff184a7c20178958a811beb9c78fa3))
* **providers/anthropic:** don't crash token estimation on null
tool_calls
([#2472](https://github.com/headroomlabs-ai/headroom/issues/2472))
([08466f3](https://github.com/headroomlabs-ai/headroom/commit/08466f3cae4dbb2647dc6f249fe42c4e840600c5))
* **providers/openai:** bound tiktoken vocab loads with the guarded
loader
([#2554](https://github.com/headroomlabs-ai/headroom/issues/2554))
([0805e8e](https://github.com/headroomlabs-ai/headroom/commit/0805e8e410543d75c7ddd3b83dde5eda3bc13144))
* **proxy/anthropic:** inject headroom_retrieve whenever a CCR marker is
present, not only for new markers
([#2848](https://github.com/headroomlabs-ai/headroom/issues/2848))
([3808f60](https://github.com/headroomlabs-ai/headroom/commit/3808f60ca61e84faf3ea8f8e003a6e6c8e9af4da))
* **proxy/anthropic:** None-guard usage token counts on the direct
buffered path
([#2434](https://github.com/headroomlabs-ai/headroom/issues/2434))
([2b5ee7c](https://github.com/headroomlabs-ai/headroom/commit/2b5ee7cde809ca37f6998d9679b1eb2133ab50ca))
* **proxy/anthropic:** run tool-search history repair after turn hooks
([c6f9948](https://github.com/headroomlabs-ai/headroom/commit/c6f99482e1bea024db6014a70c8e6da419543957))
* **proxy/batch:** don't crash an OpenAI batch on a valid-JSON
non-object line
([#2316](https://github.com/headroomlabs-ai/headroom/issues/2316))
([1f2c681](https://github.com/headroomlabs-ai/headroom/commit/1f2c681c0b48150a569277d3ebd5e95709dc7c39))
* **proxy/bedrock:** report uncached input tokens from backend usage,
not the live-zone count
([#2318](https://github.com/headroomlabs-ai/headroom/issues/2318))
([c19e412](https://github.com/headroomlabs-ai/headroom/commit/c19e412b3356d80dece001887d4ff48b6fd5150b))
* **proxy/gemini:** keep streaming-parity baseline so eligible_pct can't
exceed 100
([#2824](https://github.com/headroomlabs-ai/headroom/issues/2824))
([b97c7c6](https://github.com/headroomlabs-ai/headroom/commit/b97c7c6e99eac84df49c7a7e5f21dedb298716fe))
* **proxy/metrics:** cap client-supplied model label cardinality
([#2480](https://github.com/headroomlabs-ai/headroom/issues/2480))
([e24a7e6](https://github.com/headroomlabs-ai/headroom/commit/e24a7e66b95fa908c4ea6fd079809ece7692e6b2))
* **proxy/metrics:** escape label values in the Prometheus export
([#2463](https://github.com/headroomlabs-ai/headroom/issues/2463))
([6a53861](https://github.com/headroomlabs-ai/headroom/commit/6a53861063c3839e698bbec7194517bdfd851c38))
* **proxy/openai:** don't crash the Responses memory tool loops on null
arguments
([#2273](https://github.com/headroomlabs-ai/headroom/issues/2273))
([a30db2c](https://github.com/headroomlabs-ai/headroom/commit/a30db2cae49b4ef03ebbd404ec1fc6c4f5f2404d))
* **proxy/openai:** feed Codex WS traffic into the traffic learner
([#2334](https://github.com/headroomlabs-ai/headroom/issues/2334))
([f669149](https://github.com/headroomlabs-ai/headroom/commit/f6691497692869b7067438597421ff12aace6bf4))
* **proxy/openai:** run response hooks on Responses, and bill their
re-drives
([#2872](https://github.com/headroomlabs-ai/headroom/issues/2872))
([675d13f](https://github.com/headroomlabs-ai/headroom/commit/675d13f08d42455c8fa17bda878c1a11b905cee4))
* **proxy:** allow settings routes for trusted gateway/dashboard clients
([#2491](https://github.com/headroomlabs-ai/headroom/issues/2491))
([a5b0a8f](https://github.com/headroomlabs-ai/headroom/commit/a5b0a8f4cc54d68afcf371a422b3a4a9635b7e7f))
* **proxy:** cache litellm model resolution to stop repeated Provider
List spam
([99f07e7](https://github.com/headroomlabs-ai/headroom/commit/99f07e7bbdded9dadc70e35ee6ab025279d1aa22))
* **proxy:** cancel periodic TOIN task on shutdown
([739fdef](https://github.com/headroomlabs-ai/headroom/commit/739fdef423fa8cbc82537481c875d4570b0ecad4))
* **proxy:** close the upstream stream when a streaming body is never
consumed
([0951663](https://github.com/headroomlabs-ai/headroom/commit/09516635621caccf7e3db4f537eb49ea49b8a453))
* **proxy:** compress cache-mode cold starts and tag prefix-mismatch
passthrough
([#2365](https://github.com/headroomlabs-ai/headroom/issues/2365))
([aaeba0a](https://github.com/headroomlabs-ai/headroom/commit/aaeba0a319f12b98cad3bfcf1cf991b694b946bf))
* **proxy:** emit request log timestamps in UTC
([620028f](https://github.com/headroomlabs-ai/headroom/commit/620028fa18843622d3e454bd40fb91a93e607dbf))
* **proxy:** enable tool search by default and repair poisoned
transcripts
([#2807](https://github.com/headroomlabs-ai/headroom/issues/2807))
([0237cbf](https://github.com/headroomlabs-ai/headroom/commit/0237cbffbbc456ad8a7398005602d76881862d99))
* **proxy:** gate mid-turn message coalescing to Claude Code clients
([#1643](https://github.com/headroomlabs-ai/headroom/issues/1643))
([a4bd2e6](https://github.com/headroomlabs-ai/headroom/commit/a4bd2e62a5bb73f15b3b12e979c69e2b555bee10))
* **proxy:** give each Codex /v1/responses WS turn a unique request_id
([#2164](https://github.com/headroomlabs-ai/headroom/issues/2164))
([d02df10](https://github.com/headroomlabs-ai/headroom/commit/d02df1075894b414d60626aca2bbcadd7a3577a0))
* **proxy:** graceful shutdown and reliable Ctrl+C exit
([#621](https://github.com/headroomlabs-ai/headroom/issues/621))
([17cdb18](https://github.com/headroomlabs-ai/headroom/commit/17cdb185bc79d8cfec104e781a7e555af3ef11e1))
* **proxy:** guard telemetry and TOIN endpoints
([cde1513](https://github.com/headroomlabs-ai/headroom/commit/cde1513c91b6c6c240869bc5660f4b8966197bbc))
* **proxy:** include tool_search_deferral savings in the savings ledger
([12149f7](https://github.com/headroomlabs-ai/headroom/commit/12149f74466c08b69be8d5fe751425be63c2fda4))
* **proxy:** pass through cross-region prefixed Bedrock model IDs
directly
([#2330](https://github.com/headroomlabs-ai/headroom/issues/2330))
([64cb46e](https://github.com/headroomlabs-ai/headroom/commit/64cb46e24bf7b223ea71b14b6f5e86e78fa7ac45))
* **proxy:** port session-sticky beta headers to the Rust proxy
([#2381](https://github.com/headroomlabs-ai/headroom/issues/2381))
([f6398a6](https://github.com/headroomlabs-ai/headroom/commit/f6398a64768a095b722a5fb0b2445c7953dee1c6))
* **proxy:** preserve merged session and quarantine contracts
([#2943](https://github.com/headroomlabs-ai/headroom/issues/2943))
([039cd24](https://github.com/headroomlabs-ai/headroom/commit/039cd2431aaec7d59fefaf7e97aeda1fd7ab3afa))
* **proxy:** preserve signed Anthropic thinking blocks on outbound
re-serialize
([#2254](https://github.com/headroomlabs-ai/headroom/issues/2254))
([dc163bc](https://github.com/headroomlabs-ai/headroom/commit/dc163bcd1cba4cd8898f23286eb1365fcf6e0356))
* **proxy:** stop discarding compressed Codex WS later-frame payloads
([#2823](https://github.com/headroomlabs-ai/headroom/issues/2823))
([4ec416d](https://github.com/headroomlabs-ai/headroom/commit/4ec416df8899036544e679f561f1cf921f3da0dd))
* **proxy:** time-cap the compression timeout-debt quarantine
([#2360](https://github.com/headroomlabs-ai/headroom/issues/2360))
([#2412](https://github.com/headroomlabs-ai/headroom/issues/2412))
([c5a08d2](https://github.com/headroomlabs-ai/headroom/commit/c5a08d22e05a7dd2b929f3cca76ee3fb42f122db))
* **proxy:** unwrap Hermes tool_call bridge in tool name map
([#2717](https://github.com/headroomlabs-ai/headroom/issues/2717))
([a97b824](https://github.com/headroomlabs-ai/headroom/commit/a97b82413bdc86655c064417ed4628ff4d9d7c9d))
* publish headroom-opencode in release workflow
([#2372](https://github.com/headroomlabs-ai/headroom/issues/2372))
([7859154](https://github.com/headroomlabs-ai/headroom/commit/78591545ceb8303fdf9b93cd5ff02b626df97d2b))
* **settings:** accept documented HEADROOM_* env names as settings keys
([#2833](https://github.com/headroomlabs-ai/headroom/issues/2833))
([de9e052](https://github.com/headroomlabs-ai/headroom/commit/de9e0523dad47b700062464adecd60f82547f332))
* **subscription:** dedup transcript usage by message id
([#2340](https://github.com/headroomlabs-ai/headroom/issues/2340) token
inflation)
([#2408](https://github.com/headroomlabs-ai/headroom/issues/2408))
([74275b7](https://github.com/headroomlabs-ai/headroom/commit/74275b7c3e2b39be5198f9efa35057a5e026e665))
* **toin:** bound private query and pattern retention
([8cd1380](https://github.com/headroomlabs-ai/headroom/commit/8cd138039edbfc295080ec474325d527fb3aedf3))
* **tokenizer:** coerce non-string tool_call fields before counting
([#2801](https://github.com/headroomlabs-ai/headroom/issues/2801))
([b6f9877](https://github.com/headroomlabs-ai/headroom/commit/b6f9877c78b3fa3b1d705426bd27d74be77f4fa0))
* **tokenizer:** price CJK in the Rust fixed-ratio estimator (Python
parity)
([#2260](https://github.com/headroomlabs-ai/headroom/issues/2260))
([6840153](https://github.com/headroomlabs-ai/headroom/commit/6840153473caa0d61e982215e16a8cf54b0b6cc7))
* **transforms/adaptive-sizer:** honor max_k on small-input fast path
([#2319](https://github.com/headroomlabs-ai/headroom/issues/2319))
([8a90523](https://github.com/headroomlabs-ai/headroom/commit/8a905232091d993fac9e19a59bc449f201d4cdf3))
* **transforms/smart_crusher:** don't crash on a tool call with a null
function
([#2232](https://github.com/headroomlabs-ai/headroom/issues/2232))
([3bb02f8](https://github.com/headroomlabs-ai/headroom/commit/3bb02f8f75f12cf8258a5b1c2a7fbdc190f9d074))
* Vertex model pricing shows $0.00 for versioned model names and
vertex:anthropic provider
([#2517](https://github.com/headroomlabs-ai/headroom/issues/2517))
([eb5b5e4](https://github.com/headroomlabs-ai/headroom/commit/eb5b5e41988f5c27d29ae8ae3e5fe74e56493b8c))
* **wrap/claude:** keep --1m effective when an explicit --model is
passed through
([c093bf1](https://github.com/headroomlabs-ai/headroom/commit/c093bf11eb5f356f71367ebb7b56ae3c2b434a12))
* **wrap/opencode:** verify the opencode binary before mutating config
([ae38486](https://github.com/headroomlabs-ai/headroom/commit/ae384862a4950cec057103e9daf75e74107640df))
* **wrap/serena:** install Serena from the serena-agent PyPI wheel, not
the git source
([d7b25ae](https://github.com/headroomlabs-ai/headroom/commit/d7b25ae3bb3364cde4931509ecb65e32085e5b09))
* **wrap:** honor Copilot OAuth wire-api override and model default
([#2387](https://github.com/headroomlabs-ai/headroom/issues/2387))
([1db6d88](https://github.com/headroomlabs-ai/headroom/commit/1db6d88ab4ea25654b8277358902b7df700db6b4))
* **wrap:** serialize shared proxy startup
([#2946](https://github.com/headroomlabs-ai/headroom/issues/2946))
([e540d64](https://github.com/headroomlabs-ai/headroom/commit/e540d64febf27f2e7997d3a1a1d89478cc1ef658))
* **wrap:** stop the launch cwd from shadowing the installed package in
the proxy subprocess
([#2843](https://github.com/headroomlabs-ai/headroom/issues/2843))
([c49be26](https://github.com/headroomlabs-ai/headroom/commit/c49be269a18446779cd8a048caaa7f0ba3a3b48b))
### Performance Improvements
* cut hot-path latency 27% (token-count memo, startup preloads, JSON
scan memo)
([#2838](https://github.com/headroomlabs-ai/headroom/issues/2838))
([53af90d](https://github.com/headroomlabs-ai/headroom/commit/53af90d68c723f644a5a41dd273a606117109866))
* **proxy:** bound upstream calls and hot-path costs
([#2852](https://github.com/headroomlabs-ai/headroom/issues/2852))
([f624d3a](https://github.com/headroomlabs-ai/headroom/commit/f624d3a00ac271db7947443ddeb0c8bc2e93d3eb))
* **subscription:** skip transcripts older than the window in
compute_window_tokens
([#2861](https://github.com/headroomlabs-ai/headroom/issues/2861))
([91d6bf3](https://github.com/headroomlabs-ai/headroom/commit/91d6bf33cde777b541375fb182d4479fdd78f81b))
### Dependencies
* bump brace-expansion from 5.0.7 to 5.0.9 in /docs
([#2751](https://github.com/headroomlabs-ai/headroom/issues/2751))
([56ee57b](https://github.com/headroomlabs-ai/headroom/commit/56ee57be98bf109f0a46de522724ef169a4bc51c))
* bump bytesize from 1.3.3 to 2.4.2
([#2286](https://github.com/headroomlabs-ai/headroom/issues/2286))
([6448545](https://github.com/headroomlabs-ai/headroom/commit/6448545a7f5a1dee88bce6f0830bdbfd1c99c617))
* bump hf-hub from 0.4.3 to 0.5.0
([#2285](https://github.com/headroomlabs-ai/headroom/issues/2285))
([4925bf6](https://github.com/headroomlabs-ai/headroom/commit/4925bf6a829735977bab5000b469c3edb19c75b1))
* bump next from 16.2.10 to 16.3.0 in /docs
([#2750](https://github.com/headroomlabs-ai/headroom/issues/2750))
([0fd0b99](https://github.com/headroomlabs-ai/headroom/commit/0fd0b996a4b58a166491b145f4d3885c21b27cc0))
* bump postcss from 8.5.19 to 8.5.25 in /plugins/openclaw
([#2749](https://github.com/headroomlabs-ai/headroom/issues/2749))
([cd60ee9](https://github.com/headroomlabs-ai/headroom/commit/cd60ee9ae886b32ba5da3203e35bb6b088031fd3))
* bump postcss from 8.5.19 to 8.5.25 in /plugins/opencode
([#2748](https://github.com/headroomlabs-ai/headroom/issues/2748))
([ff4e016](https://github.com/headroomlabs-ai/headroom/commit/ff4e0167bbccbd4ae51bf23ddec144e61c94cd68))
* bump postcss from 8.5.19 to 8.5.25 in /sdk/typescript
([#2747](https://github.com/headroomlabs-ai/headroom/issues/2747))
([267c2bd](https://github.com/headroomlabs-ai/headroom/commit/267c2bdcb56e132b2dd9c065dab3498dbf730ca3))
* bump postcss from 8.5.19 to 8.5.26 in /docs
([#2881](https://github.com/headroomlabs-ai/headroom/issues/2881))
([e6e5826](https://github.com/headroomlabs-ai/headroom/commit/e6e5826423a0a700a8c544ce2c8cbcdef694160e))
* bump ruff from 0.15.17 to 0.15.22 in the pip-minor-patch group
([#2501](https://github.com/headroomlabs-ai/headroom/issues/2501))
([ecf130d](https://github.com/headroomlabs-ai/headroom/commit/ecf130d3ac6fb864098cb93fafd2621ae3ac7e12))
* bump rusqlite from 0.32.1 to 0.40.1
([#2287](https://github.com/headroomlabs-ai/headroom/issues/2287))
([522faa1](https://github.com/headroomlabs-ai/headroom/commit/522faa1a59aa94e4adfd4a4afe0202d1126e187d))
* bump the cargo-minor-patch group across 1 directory with 22 updates
([#2916](https://github.com/headroomlabs-ai/headroom/issues/2916))
([148d860](https://github.com/headroomlabs-ai/headroom/commit/148d8605e2087f3c8d6a3fa4b8d248ad2da5858f))
</details>
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
---------
Co-authored-by: JD Davis <mxjerrett@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-12 17:02:51 -07:00
|
|
|
def client(monkeypatch):
|
|
|
|
|
# Debug endpoint tests must not depend on live upstream network access.
|
|
|
|
|
# Dedicated health-check tests cover both successful and failed upstream
|
|
|
|
|
# probes in tests/test_proxy_healthchecks.py.
|
|
|
|
|
monkeypatch.setenv("HEADROOM_SKIP_UPSTREAM_CHECK", "1")
|
2026-04-17 15:42:34 +07:00
|
|
|
config = ProxyConfig(
|
|
|
|
|
optimize=False,
|
|
|
|
|
cache_enabled=False,
|
|
|
|
|
rate_limit_enabled=False,
|
|
|
|
|
cost_tracking_enabled=False,
|
|
|
|
|
)
|
|
|
|
|
app = create_app(config)
|
|
|
|
|
# Pin the simulated client address to loopback so the /debug/* guard
|
|
|
|
|
# accepts the request. Without this, FastAPI's TestClient reports
|
|
|
|
|
# the host as ``testclient`` and the guard correctly 404s us.
|
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 13:58:33 -04:00
|
|
|
# ``base_url`` pins the inbound ``Host:`` header to a loopback name
|
|
|
|
|
# so the DNS-rebinding gate added in 2026-06 also passes.
|
|
|
|
|
with TestClient(
|
|
|
|
|
app,
|
|
|
|
|
base_url="http://127.0.0.1",
|
|
|
|
|
client=("127.0.0.1", 12345),
|
|
|
|
|
) as test_client:
|
2026-04-17 15:42:34 +07:00
|
|
|
yield test_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def app_and_client():
|
|
|
|
|
config = ProxyConfig(
|
|
|
|
|
optimize=False,
|
|
|
|
|
cache_enabled=False,
|
|
|
|
|
rate_limit_enabled=False,
|
|
|
|
|
cost_tracking_enabled=False,
|
|
|
|
|
)
|
|
|
|
|
app = create_app(config)
|
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 13:58:33 -04:00
|
|
|
with TestClient(
|
|
|
|
|
app,
|
|
|
|
|
base_url="http://127.0.0.1",
|
|
|
|
|
client=("127.0.0.1", 12345),
|
|
|
|
|
) as test_client:
|
2026-04-17 15:42:34 +07:00
|
|
|
yield app, test_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def app_and_external_client():
|
|
|
|
|
"""TestClient that reports a non-loopback address (to exercise 404)."""
|
|
|
|
|
config = ProxyConfig(
|
|
|
|
|
optimize=False,
|
|
|
|
|
cache_enabled=False,
|
|
|
|
|
rate_limit_enabled=False,
|
|
|
|
|
cost_tracking_enabled=False,
|
|
|
|
|
)
|
|
|
|
|
app = create_app(config)
|
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 13:58:33 -04:00
|
|
|
with TestClient(
|
|
|
|
|
app,
|
|
|
|
|
base_url="http://127.0.0.1",
|
|
|
|
|
client=("10.0.0.1", 54321),
|
|
|
|
|
) as test_client:
|
|
|
|
|
yield app, test_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def app_and_rebinding_client():
|
|
|
|
|
"""TestClient that simulates a DNS-rebinding attack.
|
|
|
|
|
|
|
|
|
|
The simulated TCP peer is loopback (``request.client.host`` passes
|
|
|
|
|
the legacy IP check), but the inbound ``Host:`` header reads
|
|
|
|
|
``attacker.com`` — exactly what the browser sends after the
|
|
|
|
|
attacker's DNS record flips to ``127.0.0.1``.
|
|
|
|
|
"""
|
|
|
|
|
config = ProxyConfig(
|
|
|
|
|
optimize=False,
|
|
|
|
|
cache_enabled=False,
|
|
|
|
|
rate_limit_enabled=False,
|
|
|
|
|
cost_tracking_enabled=False,
|
|
|
|
|
)
|
|
|
|
|
app = create_app(config)
|
|
|
|
|
with TestClient(
|
|
|
|
|
app,
|
|
|
|
|
base_url="http://attacker.com",
|
|
|
|
|
client=("127.0.0.1", 12345),
|
|
|
|
|
) as test_client:
|
2026-04-17 15:42:34 +07:00
|
|
|
yield app, test_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Loopback guard unit tests
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_loopback_host_accepts_canonical_hosts():
|
|
|
|
|
for host in LOOPBACK_HOSTS:
|
|
|
|
|
assert is_loopback_host(host) is True
|
|
|
|
|
# None (TestClient with no client info) is treated as loopback.
|
|
|
|
|
assert is_loopback_host(None) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_loopback_host_rejects_external_hosts():
|
|
|
|
|
assert is_loopback_host("10.0.0.1") is False
|
|
|
|
|
assert is_loopback_host("192.168.1.100") is False
|
|
|
|
|
assert is_loopback_host("8.8.8.8") is False
|
|
|
|
|
|
|
|
|
|
|
2026-04-18 01:55:49 +07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 15:42:34 +07:00
|
|
|
def test_require_loopback_raises_404_for_external_client():
|
|
|
|
|
class _FakeClient:
|
|
|
|
|
host = "10.0.0.1"
|
|
|
|
|
|
|
|
|
|
class _FakeRequest:
|
|
|
|
|
client = _FakeClient()
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
require_loopback(_FakeRequest()) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
# Privacy: 404 explicitly, not 403 — endpoints should be invisible.
|
|
|
|
|
assert exc_info.value.status_code != 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_require_loopback_accepts_loopback_client():
|
|
|
|
|
class _FakeClient:
|
|
|
|
|
host = "127.0.0.1"
|
|
|
|
|
|
|
|
|
|
class _FakeRequest:
|
|
|
|
|
client = _FakeClient()
|
|
|
|
|
|
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 13:58:33 -04:00
|
|
|
# Should not raise. ``headers`` is absent so the Host-header gate
|
|
|
|
|
# falls back to the legacy IP-only behaviour for callers that
|
|
|
|
|
# construct a bare request stub.
|
|
|
|
|
require_loopback(_FakeRequest()) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Host-header (DNS-rebinding) guard unit tests
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_loopback_host_header_accepts_canonical_values():
|
|
|
|
|
for value in (
|
|
|
|
|
"127.0.0.1",
|
|
|
|
|
"127.0.0.1:8787",
|
|
|
|
|
"localhost",
|
|
|
|
|
"localhost:8787",
|
|
|
|
|
"LOCALHOST",
|
|
|
|
|
"Localhost:8787",
|
|
|
|
|
"[::1]",
|
|
|
|
|
"[::1]:8787",
|
|
|
|
|
):
|
|
|
|
|
assert is_loopback_host_header(value) is True, value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_loopback_host_header_rejects_external_names():
|
|
|
|
|
for value in (
|
|
|
|
|
"attacker.com",
|
|
|
|
|
"attacker.com:8787",
|
|
|
|
|
"evil.example",
|
|
|
|
|
"10.0.0.1",
|
|
|
|
|
"10.0.0.1:8787",
|
|
|
|
|
"8.8.8.8",
|
|
|
|
|
):
|
|
|
|
|
assert is_loopback_host_header(value) is False, value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_loopback_host_header_rejects_missing_and_malformed():
|
|
|
|
|
assert is_loopback_host_header(None) is False
|
|
|
|
|
assert is_loopback_host_header("") is False
|
|
|
|
|
assert is_loopback_host_header(" ") is False
|
|
|
|
|
# Unterminated bracketed IPv6
|
|
|
|
|
assert is_loopback_host_header("[::1") is False
|
|
|
|
|
# Hostname that merely contains a loopback substring
|
|
|
|
|
assert is_loopback_host_header("localhost.attacker.com") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_require_loopback_blocks_dns_rebinding_host_header():
|
|
|
|
|
"""Loopback IP + ``Host: attacker.com`` is the rebinding signature."""
|
|
|
|
|
|
|
|
|
|
class _FakeClient:
|
|
|
|
|
host = "127.0.0.1"
|
|
|
|
|
|
|
|
|
|
class _FakeHeaders:
|
|
|
|
|
def get(self, key, default=None):
|
|
|
|
|
if key.lower() == "host":
|
|
|
|
|
return "attacker.com"
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
class _FakeRequest:
|
|
|
|
|
client = _FakeClient()
|
|
|
|
|
headers = _FakeHeaders()
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
require_loopback(_FakeRequest()) # type: ignore[arg-type]
|
|
|
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_require_loopback_accepts_loopback_host_header():
|
|
|
|
|
class _FakeClient:
|
|
|
|
|
host = "127.0.0.1"
|
|
|
|
|
|
|
|
|
|
class _FakeHeaders:
|
|
|
|
|
def get(self, key, default=None):
|
|
|
|
|
if key.lower() == "host":
|
|
|
|
|
return "127.0.0.1:8787"
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
class _FakeRequest:
|
|
|
|
|
client = _FakeClient()
|
|
|
|
|
headers = _FakeHeaders()
|
|
|
|
|
|
|
|
|
|
# Should not raise — both gates pass.
|
2026-04-17 15:42:34 +07:00
|
|
|
require_loopback(_FakeRequest()) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Serializer unit tests
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
2026-04-18 02:13:36 +07:00
|
|
|
def test_warmup_registry_to_dict_returns_registry_shape():
|
|
|
|
|
"""Serializer equivalent of the old collect_warmup helper.
|
2026-04-17 15:42:34 +07:00
|
|
|
|
2026-04-18 02:13:36 +07:00
|
|
|
The helper was inlined at the /debug/warmup route handler in server.py
|
|
|
|
|
(``registry.to_dict() if registry else {}``); this test preserves
|
|
|
|
|
coverage of the registry's own serializer contract.
|
|
|
|
|
"""
|
2026-04-17 15:42:34 +07:00
|
|
|
registry = WarmupRegistry()
|
|
|
|
|
registry.kompress.mark_loaded(handle=object(), source_status="enabled")
|
|
|
|
|
registry.memory_backend.mark_error("boom")
|
|
|
|
|
|
2026-04-18 02:13:36 +07:00
|
|
|
payload = registry.to_dict()
|
2026-04-17 15:42:34 +07:00
|
|
|
|
|
|
|
|
assert payload["kompress"]["status"] == "loaded"
|
|
|
|
|
assert payload["memory_backend"]["status"] == "error"
|
|
|
|
|
assert payload["memory_backend"]["error"] == "boom"
|
|
|
|
|
# Raw handle must never leak into the serialized payload.
|
|
|
|
|
assert "handle" not in payload["kompress"]
|
|
|
|
|
|
|
|
|
|
|
2026-04-18 02:13:36 +07:00
|
|
|
def test_ws_session_registry_snapshot_returns_registered_entries():
|
|
|
|
|
"""Serializer equivalent of the old collect_ws_sessions helper."""
|
2026-04-17 15:42:34 +07:00
|
|
|
reg = WebSocketSessionRegistry()
|
|
|
|
|
handle = WSSessionHandle(
|
|
|
|
|
session_id="sess-debug-1",
|
|
|
|
|
request_id="req-debug-1",
|
|
|
|
|
client_addr="127.0.0.1:9999",
|
|
|
|
|
upstream_url="wss://upstream/test",
|
|
|
|
|
)
|
|
|
|
|
reg.register(handle)
|
|
|
|
|
|
2026-04-18 02:13:36 +07:00
|
|
|
payload = reg.snapshot()
|
2026-04-17 15:42:34 +07:00
|
|
|
assert len(payload) == 1
|
|
|
|
|
assert payload[0]["session_id"] == "sess-debug-1"
|
|
|
|
|
assert payload[0]["request_id"] == "req-debug-1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_collect_tasks_returns_current_tasks_with_metadata():
|
|
|
|
|
async def _noop_task():
|
|
|
|
|
await asyncio.sleep(0.05)
|
|
|
|
|
|
|
|
|
|
task = asyncio.create_task(_noop_task(), name="debug-test-task")
|
|
|
|
|
try:
|
|
|
|
|
entries = collect_tasks()
|
|
|
|
|
matching = [e for e in entries if e["name"] == "debug-test-task"]
|
|
|
|
|
assert matching, "expected the named task to appear in collect_tasks output"
|
|
|
|
|
entry = matching[0]
|
|
|
|
|
assert entry["coro_qualname"] is not None
|
|
|
|
|
# Privacy: no frame locals, no coroutine args.
|
|
|
|
|
assert "locals" not in entry
|
|
|
|
|
assert "cr_frame" not in entry
|
|
|
|
|
assert "args" not in entry
|
|
|
|
|
assert entry["stack_depth"] is None or isinstance(entry["stack_depth"], int)
|
|
|
|
|
finally:
|
|
|
|
|
task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await task
|
|
|
|
|
except (asyncio.CancelledError, BaseException):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_collect_tasks_derives_age_from_ws_registry_for_codex_relays():
|
|
|
|
|
reg = WebSocketSessionRegistry()
|
|
|
|
|
sid = "relay-sess-1"
|
|
|
|
|
reg.register(
|
|
|
|
|
WSSessionHandle(
|
|
|
|
|
session_id=sid,
|
|
|
|
|
request_id="req-relay-1",
|
|
|
|
|
client_addr="127.0.0.1:1",
|
|
|
|
|
upstream_url="wss://upstream",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _long_relay():
|
|
|
|
|
await asyncio.sleep(0.2)
|
|
|
|
|
|
|
|
|
|
relay_task = asyncio.create_task(_long_relay(), name=f"codex-ws-c2u-{sid}")
|
|
|
|
|
try:
|
|
|
|
|
await asyncio.sleep(0.02) # let some age accrue
|
|
|
|
|
entries = collect_tasks(ws_registry=reg)
|
|
|
|
|
named = [e for e in entries if e["name"] == f"codex-ws-c2u-{sid}"]
|
|
|
|
|
assert named, "expected relay task in output"
|
|
|
|
|
entry = named[0]
|
|
|
|
|
assert entry["age_seconds"] is not None
|
|
|
|
|
assert entry["age_seconds"] >= 0.0
|
|
|
|
|
finally:
|
|
|
|
|
relay_task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await relay_task
|
|
|
|
|
except (asyncio.CancelledError, BaseException):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# HTTP endpoint tests (loopback)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_debug_tasks_returns_json_array_for_loopback(client):
|
|
|
|
|
response = client.get("/debug/tasks")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.json()
|
|
|
|
|
assert isinstance(data, list)
|
|
|
|
|
# Each entry at least has name + coro_qualname fields.
|
|
|
|
|
for entry in data:
|
|
|
|
|
assert "name" in entry
|
|
|
|
|
assert "coro_qualname" in entry
|
|
|
|
|
|
|
|
|
|
|
2026-04-18 02:17:37 +07:00
|
|
|
def test_debug_tasks_stack_depth_is_gated_behind_query(client):
|
|
|
|
|
"""Default response must not compute stack_depth (P3 Fix 29 perf gate).
|
|
|
|
|
|
|
|
|
|
``?stack=true`` opts into the synchronous ``Task.get_stack`` walk; the
|
|
|
|
|
default stays cheap so snapshotting during a reconnect storm does
|
|
|
|
|
not stall the event loop.
|
|
|
|
|
"""
|
|
|
|
|
default = client.get("/debug/tasks")
|
|
|
|
|
assert default.status_code == 200
|
|
|
|
|
for entry in default.json():
|
|
|
|
|
assert entry["stack_depth"] is None, (
|
|
|
|
|
f"default /debug/tasks must not compute stack_depth; "
|
|
|
|
|
f"got {entry['stack_depth']!r} for {entry.get('name')!r}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with_stack = client.get("/debug/tasks?stack=true")
|
|
|
|
|
assert with_stack.status_code == 200
|
|
|
|
|
entries = with_stack.json()
|
|
|
|
|
# At least one entry should have a computed depth (the TestClient
|
|
|
|
|
# itself runs under a task). Some entries may still be None if
|
|
|
|
|
# get_stack raised defensively — we only require that opting in
|
|
|
|
|
# produces at least one integer result.
|
2026-04-18 08:22:04 +07:00
|
|
|
integer_depths = [e["stack_depth"] for e in entries if isinstance(e["stack_depth"], int)]
|
2026-04-18 02:17:37 +07:00
|
|
|
assert integer_depths, (
|
2026-04-18 08:22:04 +07:00
|
|
|
f"expected at least one int stack_depth when ?stack=true; got entries={entries!r}"
|
2026-04-18 02:17:37 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 15:42:34 +07:00
|
|
|
def test_debug_warmup_reports_registry_slots(client):
|
|
|
|
|
response = client.get("/debug/warmup")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.json()
|
|
|
|
|
# Registry surfaces all canonical slot names.
|
|
|
|
|
assert "kompress" in data
|
|
|
|
|
assert "magika" in data
|
|
|
|
|
assert "memory_backend" in data
|
|
|
|
|
assert "memory_embedder" in data
|
2026-04-20 22:38:26 +07:00
|
|
|
assert "runtime" in data
|
2026-04-17 15:42:34 +07:00
|
|
|
# Each slot has at least a status field.
|
|
|
|
|
assert "status" in data["memory_backend"]
|
2026-04-20 22:38:26 +07:00
|
|
|
assert data["runtime"]["anthropic_pre_upstream"]["resolved_concurrency"] >= 0
|
|
|
|
|
assert data["runtime"]["websocket_sessions"]["active_relay_tasks"] == 0
|
2026-04-17 15:42:34 +07:00
|
|
|
|
|
|
|
|
|
fix(proxy/debug): reconcile Kompress warmup state in /debug/warmup (#2711)
## Description
`/debug/warmup` serialized the warmup registry verbatim, so a Kompress
slot left at the startup snapshot kept reporting `{"status": "null",
"info": {"source_status": "deferred"}}` forever — even while the ONNX
model was loaded and actively compressing.
`/health` and `/readyz` already fix this: #2402 added
`_reconcile_kompress_health()`, which promotes the slot from live
runtime state. The debug route never called it, so its answer depended
on whether a health probe happened to run first. That is the half of
#2624 still reproducing on `main`.
Second defect: `WarmupSlot.mark_loaded()` only *updates* `info`, so the
startup-planted `source_status: "deferred"` survived promotion and the
slot serialized as the self-contradictory `{"status": "loaded", "info":
{"source_status": "deferred", "backend": "onnx"}}`.
Closes #2624
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/proxy/server.py`: call the existing
`_reconcile_kompress_health()` in the `/debug/warmup` route before
serializing the registry. The reconciler never instantiates a compressor
and never calls `preload()` / `ensure_background_load()` / `compress()`
— it only reads `is_ready()` / `ready_backend()` on an already resident
instance, or falls back to the module-level ONNX cache — so the endpoint
stays side-effect free and idempotent.
- `headroom/proxy/server.py`: stamp `source_status="runtime"` at both
`mark_loaded()` promotion sites in `_reconcile_kompress_health()` (the
resident-compressor path and the `_kompress_cache` fallback),
overwriting the stale startup marker.
- `tests/test_proxy_debug_endpoints.py`: three regression tests plus a
read-only compressor stub whose `preload` / `ensure_background_load`
raise, so a future change that makes the debug route trigger a load
fails loudly.
- `tests/test_proxy_health.py`: assert the promoted slot's
`info["source_status"] == "runtime"`.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ pytest tests/test_proxy_debug_endpoints.py tests/test_proxy_health.py tests/test_proxy_warmup.py -q
tests\test_proxy_debug_endpoints.py ............................. [ 52%]
tests\test_proxy_health.py ................. [ 83%]
tests\test_proxy_warmup.py ......... [100%]
============================= 55 passed in 36.56s =============================
$ ruff check headroom/proxy/server.py tests/test_proxy_debug_endpoints.py tests/test_proxy_health.py
All checks passed!
$ ruff format --check headroom/proxy/server.py tests/test_proxy_debug_endpoints.py tests/test_proxy_health.py
3 files already formatted
$ mypy headroom --ignore-missing-imports
Success: no issues found in 506 source files
```
The three new tests were confirmed to be genuine regression tests: with
the `server.py` change reverted and the tests kept, all three fail.
```text
$ git stash push -- headroom/proxy/server.py && pytest tests/test_proxy_debug_endpoints.py -q -k kompress
FAILED tests/test_proxy_debug_endpoints.py::test_debug_warmup_promotes_deferred_kompress_after_runtime_load
FAILED tests/test_proxy_debug_endpoints.py::test_debug_warmup_keeps_pending_kompress_null
FAILED tests/test_proxy_debug_endpoints.py::test_debug_warmup_never_starts_kompress_loading
====================== 3 failed, 26 deselected in 3.98s =======================
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.13.11, pytest 9.1.1, ruff 0.15.x,
mypy 1.20.2, branch based on `main` at 6d5516dc
- Exact command / steps: `pytest tests/test_proxy_debug_endpoints.py
tests/test_proxy_health.py tests/test_proxy_warmup.py -q`, then `git
stash push -- headroom/proxy/server.py` and re-run `pytest
tests/test_proxy_debug_endpoints.py -q -k kompress` to confirm the new
tests fail without the fix
- Observed result: 55 passed with the fix. Without the fix the three new
`/debug/warmup` tests fail — the slot stays `status: "null"` with
`info.source_status: "deferred"` and the stub records zero calls, i.e.
the endpoint never looked at live runtime state. With the fix the same
slot serializes as `{"status": "loaded", "info": {"source_status":
"runtime", "backend": "onnx"}}` and the stub records exactly
`["is_ready", "ready_backend"]` — no load triggered.
- Not tested: the live end-to-end proxy path (cold start, real ONNX
download, real request traffic). This machine has no `onnxruntime` /
`transformers` installed, so a real Kompress load cannot run here; the
tests substitute a stub at the same seam `_reconcile_kompress_health()`
reads. Unrelated to this change, that missing-dependency environment
also makes the pre-existing
`tests/test_kompress_preload_deferral.py::test_proxy_startup_does_not_enter_cached_kompress_native_loader`
fail locally (it reports `source_status: "unavailable"` instead of
`"deferred"`); it fails identically on unmodified `main`.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:15:01 +02:00
|
|
|
class _KompressStub:
|
|
|
|
|
"""Read-only stand-in exposing the accessors the health reconciler uses.
|
|
|
|
|
|
|
|
|
|
``preload`` / ``ensure_background_load`` raise so the tests fail loudly if
|
|
|
|
|
``/debug/warmup`` ever triggers a model load instead of just observing.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, *, backend="onnx", ready=True):
|
|
|
|
|
self.backend = backend
|
|
|
|
|
self.ready = ready
|
|
|
|
|
self.calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
def is_ready(self):
|
|
|
|
|
self.calls.append("is_ready")
|
|
|
|
|
return self.ready
|
|
|
|
|
|
|
|
|
|
def ready_backend(self):
|
|
|
|
|
self.calls.append("ready_backend")
|
|
|
|
|
return self.backend
|
|
|
|
|
|
|
|
|
|
def preload(self):
|
|
|
|
|
raise AssertionError("/debug/warmup must never preload kompress")
|
|
|
|
|
|
|
|
|
|
def ensure_background_load(self):
|
|
|
|
|
raise AssertionError("/debug/warmup must never start a background load")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
def _deferred_kompress_client(compressor):
|
|
|
|
|
"""Client whose kompress slot still carries the startup ``deferred`` mark.
|
|
|
|
|
|
|
|
|
|
Mirrors the real cold-start shape: ``eager_load_compressors`` reported
|
|
|
|
|
``deferred``, the model then loaded on the request path, and nothing wrote
|
|
|
|
|
the promotion back to the registry.
|
|
|
|
|
"""
|
|
|
|
|
config = ProxyConfig(
|
|
|
|
|
optimize=False,
|
|
|
|
|
cache_enabled=False,
|
|
|
|
|
rate_limit_enabled=False,
|
|
|
|
|
cost_tracking_enabled=False,
|
|
|
|
|
)
|
|
|
|
|
app = create_app(config)
|
|
|
|
|
with TestClient(
|
|
|
|
|
app,
|
|
|
|
|
base_url="http://127.0.0.1",
|
|
|
|
|
client=("127.0.0.1", 12345),
|
|
|
|
|
) as test_client:
|
|
|
|
|
proxy = app.state.proxy
|
|
|
|
|
router = proxy.anthropic_pipeline.transforms[-1]
|
|
|
|
|
router._kompress = compressor
|
|
|
|
|
proxy.warmup.kompress.mark_null()
|
|
|
|
|
proxy.warmup.kompress.info["source_status"] = "deferred"
|
|
|
|
|
yield proxy, test_client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_kompress_cache(monkeypatch):
|
|
|
|
|
"""Neutralize the process-global ONNX cache the reconciler falls back to."""
|
|
|
|
|
try:
|
|
|
|
|
from headroom.transforms import kompress_compressor
|
|
|
|
|
except ImportError:
|
|
|
|
|
return
|
|
|
|
|
monkeypatch.setattr(kompress_compressor, "_kompress_cache", {}, raising=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_debug_warmup_promotes_deferred_kompress_after_runtime_load():
|
|
|
|
|
compressor = _KompressStub()
|
|
|
|
|
with _deferred_kompress_client(compressor) as (_proxy, client):
|
|
|
|
|
slot = client.get("/debug/warmup").json()["kompress"]
|
|
|
|
|
|
|
|
|
|
assert slot["status"] == "loaded"
|
|
|
|
|
assert slot["info"]["backend"] == "onnx"
|
|
|
|
|
assert slot["info"]["source_status"] == "runtime"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_debug_warmup_keeps_pending_kompress_null(monkeypatch):
|
|
|
|
|
_clear_kompress_cache(monkeypatch)
|
|
|
|
|
compressor = _KompressStub(ready=False)
|
|
|
|
|
with _deferred_kompress_client(compressor) as (_proxy, client):
|
|
|
|
|
slot = client.get("/debug/warmup").json()["kompress"]
|
|
|
|
|
|
|
|
|
|
assert slot["status"] == "null"
|
|
|
|
|
assert slot["info"]["source_status"] == "deferred"
|
|
|
|
|
assert compressor.calls == ["is_ready"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_debug_warmup_never_starts_kompress_loading():
|
|
|
|
|
compressor = _KompressStub()
|
|
|
|
|
with _deferred_kompress_client(compressor) as (_proxy, client):
|
|
|
|
|
client.get("/debug/warmup")
|
|
|
|
|
|
|
|
|
|
# Observation only: no preload(), no ensure_background_load(), no compress().
|
|
|
|
|
assert compressor.calls == ["is_ready", "ready_backend"]
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 15:42:34 +07:00
|
|
|
def test_debug_ws_sessions_reports_live_session(app_and_client):
|
|
|
|
|
app, client = app_and_client
|
|
|
|
|
proxy = app.state.proxy
|
|
|
|
|
assert proxy is not None, "create_app must wire app.state.proxy"
|
|
|
|
|
|
|
|
|
|
sid = "sess-debug-http"
|
|
|
|
|
proxy.ws_sessions.register(
|
|
|
|
|
WSSessionHandle(
|
|
|
|
|
session_id=sid,
|
|
|
|
|
request_id="req-debug-http",
|
|
|
|
|
client_addr="127.0.0.1:12345",
|
|
|
|
|
upstream_url="wss://upstream/test",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
response = client.get("/debug/ws-sessions")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
data = response.json()
|
|
|
|
|
matching = [entry for entry in data if entry["session_id"] == sid]
|
|
|
|
|
assert matching, "expected live session in /debug/ws-sessions output"
|
|
|
|
|
assert matching[0]["request_id"] == "req-debug-http"
|
|
|
|
|
finally:
|
|
|
|
|
proxy.ws_sessions.deregister(sid, cause="response_completed")
|
|
|
|
|
|
|
|
|
|
# After cleanup the session is gone.
|
|
|
|
|
response = client.get("/debug/ws-sessions")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert all(entry["session_id"] != sid for entry in response.json())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_debug_endpoints_do_not_mutate_state(client):
|
|
|
|
|
# Call each endpoint 100 times and confirm the second read equals
|
|
|
|
|
# the first — no accidental mutation from serialization.
|
|
|
|
|
first_tasks = client.get("/debug/tasks").json()
|
|
|
|
|
first_warmup = client.get("/debug/warmup").json()
|
|
|
|
|
first_ws = client.get("/debug/ws-sessions").json()
|
|
|
|
|
|
|
|
|
|
for _ in range(100):
|
|
|
|
|
client.get("/debug/tasks")
|
|
|
|
|
client.get("/debug/warmup")
|
|
|
|
|
client.get("/debug/ws-sessions")
|
|
|
|
|
|
|
|
|
|
# Warmup and ws-sessions are deterministic (no background work touches
|
|
|
|
|
# them in this test config), so they must be identical.
|
|
|
|
|
assert client.get("/debug/warmup").json() == first_warmup
|
|
|
|
|
assert client.get("/debug/ws-sessions").json() == first_ws
|
|
|
|
|
# Tasks may vary naturally, but the call itself never raises and the
|
|
|
|
|
# shape never changes.
|
|
|
|
|
new_tasks = client.get("/debug/tasks").json()
|
|
|
|
|
assert isinstance(new_tasks, list)
|
|
|
|
|
for entry in new_tasks:
|
|
|
|
|
assert set(entry.keys()) == set(first_tasks[0].keys()) if first_tasks else True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_debug_tasks_does_not_leak_coro_locals(client):
|
|
|
|
|
response = client.get("/debug/tasks")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
for entry in response.json():
|
|
|
|
|
# Privacy check: the serializer must not leak coroutine locals,
|
|
|
|
|
# frame state, or request bodies. Only name / qualname / age /
|
|
|
|
|
# depth / done are allowed.
|
|
|
|
|
assert set(entry.keys()) <= {
|
|
|
|
|
"name",
|
|
|
|
|
"coro_qualname",
|
|
|
|
|
"age_seconds",
|
|
|
|
|
"stack_depth",
|
|
|
|
|
"done",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# HTTP endpoint tests (non-loopback)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_debug_endpoints_return_404_for_non_loopback_client(app_and_external_client):
|
|
|
|
|
_, client = app_and_external_client
|
|
|
|
|
for path in ("/debug/tasks", "/debug/ws-sessions", "/debug/warmup"):
|
|
|
|
|
response = client.get(path)
|
|
|
|
|
assert response.status_code == 404, path
|
|
|
|
|
# Must be 404, not 403 — invisible to scanners.
|
|
|
|
|
assert response.status_code != 403
|
|
|
|
|
|
|
|
|
|
|
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 13:58:33 -04:00
|
|
|
def test_debug_endpoints_block_dns_rebinding(app_and_rebinding_client):
|
|
|
|
|
"""Loopback client + ``Host: attacker.com`` must 404 like an external client.
|
|
|
|
|
|
|
|
|
|
Regression for the DNS-rebinding gap: prior to 2026-06 the guard
|
|
|
|
|
only checked ``request.client.host``, which a rebound browser
|
|
|
|
|
passes trivially. Adding a ``Host:`` header allowlist closes that
|
|
|
|
|
gap so a malicious site cannot read /debug/* over the user's
|
|
|
|
|
loopback proxy via the wide-open CORS policy.
|
|
|
|
|
"""
|
|
|
|
|
_, client = app_and_rebinding_client
|
|
|
|
|
for path in ("/debug/tasks", "/debug/ws-sessions", "/debug/warmup"):
|
|
|
|
|
response = client.get(path)
|
|
|
|
|
assert response.status_code == 404, path
|
|
|
|
|
assert response.status_code != 403
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 15:42:34 +07:00
|
|
|
def test_existing_health_routes_unchanged(client):
|
|
|
|
|
# Invariant: Unit 5 must not regress the existing health endpoints.
|
|
|
|
|
for path in ("/livez", "/readyz", "/health"):
|
|
|
|
|
response = client.get(path)
|
|
|
|
|
assert response.status_code == 200, path
|