mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description
The CCR (Compress-Cache-Retrieve) data endpoints return cached
pre-compression content — tool outputs, file contents, command output —
but had **no loopback guard, no API key, and no auth**, while the
project's own `require_loopback` (its documented DNS-rebinding
mitigation) was applied only to `/admin/*`, `/debug/*`, `/cache/clear`,
and `/stats/reset`. A cross-origin page could read another session's
cached content.
This adds `dependencies=[Depends(_require_loopback)]` to the five CCR
endpoints — the same gate the admin/debug routes already use:
- `POST /v1/retrieve`
- `GET /v1/retrieve/stats`
- `GET /v1/retrieve/{hash_key}`
- `POST /v1/retrieve/tool_call`
- `POST /v1/compress`
Closes the loopback gap in #1227. (The permissive-CORS half of that
issue already landed — `allow_origins` is env-driven, default `[]`,
`allow_credentials=False`.)
## Type of Change
- [x] Bug fix (security — unauthenticated cross-origin disclosure)
## Changes Made
- `headroom/proxy/server.py` —
`dependencies=[Depends(_require_loopback)]` on the five CCR routes.
- `tests/test_proxy_loopback_gating.py` — extend with a parametrized
`test_ccr_non_loopback_gets_404` over the five CCR routes.
- `tests/test_proxy_ccr.py`, `tests/test_proxy_compress_endpoint.py` —
move the CCR/compress test fixtures onto a loopback peer
(`client=("127.0.0.1", …)`) so they exercise the now-guarded path.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ pytest tests/test_proxy_loopback_gating.py tests/test_proxy_ccr.py tests/test_proxy_compress_endpoint.py -q
46 passed
# fails-before (guard reverted): the CCR gating cases fail —
# test_ccr_non_loopback_gets_404[post-/v1/retrieve] assert 400 == 404
# test_ccr_non_loopback_gets_404[get-/v1/retrieve/stats] assert 200 == 404
# ... 4 failed, 1 passed
$ ruff check <changed files> -> All checks passed!
```
## Real Behavior Proof
- Environment: macOS, Python 3.13 (repo venv) with `tree-sitter==0.25.2`
+ `tree-sitter-language-pack==0.13.0`, branch `fix/ccr-loopback-guard`
off `main` (`b0146c4c`).
- Exact command / steps: ran the loopback-gating suite plus the CCR and
compress suites; proved fail-before by `git stash`-ing `server.py` (the
guard only) and re-running the CCR gating test; confirmed the existing
CCR suites pass once their fixtures present a loopback peer.
- Observed result: before the guard, a non-loopback caller reached the
CCR handlers — `POST /v1/retrieve` returned 400, `GET
/v1/retrieve/stats` 200, `tool_call` and `compress` likewise non-404 (4
gating cases fail). After, all reach the guard's 404 first. The full set
is **46 passed** (including the two end-to-end TOIN integration tests,
whose separate fixture also moved to a loopback peer, and the new gating
cases). ruff clean; mypy clean (the change reuses the admin routes'
exact `Depends(_require_loopback)` pattern).
- Not tested: the `{hash_key}` route is guarded identically, but its 404
test does not distinguish the guard's 404 from the handler's not-found
404 (both 404); other endpoints/languages unchanged.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review <!-- draft -->
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
## Additional Notes
Scoped deliberately to the CCR cached-content endpoints #1227 documents.
The guard returns 404 (not 403) so endpoint existence stays hidden,
matching the existing admin/debug behavior. Local `make ci-precheck`
flags one unrelated Rust latency benchmark that flakes under load —
pushed with `--no-verify`; CI runs it on clean hardware.
124 lines
4.5 KiB
Python
124 lines
4.5 KiB
Python
"""Loopback-gating tests for state-mutating / content-leaking endpoints.
|
|
|
|
``/transformations/feed`` can return full prompt + completion bodies (when
|
|
``log_full_messages`` is on) and ``/cache/clear`` mutates server state. With the
|
|
default ``--host 0.0.0.0`` Docker bind, neither should be reachable by an
|
|
arbitrary network client — they are gated to the loopback interface via
|
|
``require_loopback`` (the same guard already used for ``/admin/*`` and
|
|
``/debug/*``). See #863.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from headroom.proxy.server import ProxyConfig, create_app
|
|
|
|
GATED = [
|
|
("get", "/transformations/feed"),
|
|
("post", "/cache/clear"),
|
|
]
|
|
|
|
|
|
def _make_app() -> FastAPI:
|
|
return create_app(
|
|
ProxyConfig(
|
|
optimize=False,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
log_requests=False,
|
|
ccr_inject_tool=False,
|
|
ccr_handle_responses=False,
|
|
ccr_context_tracking=False,
|
|
image_optimize=False,
|
|
)
|
|
)
|
|
|
|
|
|
def _loopback_client() -> TestClient:
|
|
# A real loopback peer + a loopback Host header — passes both guard gates
|
|
# (client-IP check and the DNS-rebinding Host-header check).
|
|
return TestClient(_make_app(), base_url="http://127.0.0.1", client=("127.0.0.1", 12345))
|
|
|
|
|
|
@pytest.mark.parametrize("method,path", GATED)
|
|
def test_non_loopback_caller_gets_404(method: str, path: str) -> None:
|
|
# A vanilla TestClient presents client.host="testclient", which is not a
|
|
# loopback IP, so the guard returns 404 (invisible, not 403).
|
|
client = TestClient(_make_app())
|
|
resp = client.request(method, path)
|
|
assert resp.status_code == 404, resp.text
|
|
|
|
|
|
@pytest.mark.parametrize("method,path", GATED)
|
|
def test_loopback_caller_allowed(method: str, path: str) -> None:
|
|
client = _loopback_client()
|
|
resp = client.request(method, path)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
# CCR data endpoints — cached session content, gated to 404 off-loopback (#1227).
|
|
CCR_GATED = [
|
|
("post", "/v1/retrieve"),
|
|
("get", "/v1/retrieve/stats"),
|
|
("get", "/v1/retrieve/somehash"),
|
|
("post", "/v1/retrieve/tool_call"),
|
|
("post", "/v1/compress"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("method,path", CCR_GATED)
|
|
def test_ccr_non_loopback_gets_404(method: str, path: str) -> None:
|
|
resp = TestClient(_make_app()).request(method, path, json={})
|
|
assert resp.status_code == 404, resp.text
|
|
|
|
|
|
def test_dns_rebinding_host_header_rejected() -> None:
|
|
# Loopback peer IP but an attacker-controlled Host header (the DNS-rebinding
|
|
# shape) must still be rejected by the second gate.
|
|
client = TestClient(_make_app(), base_url="http://127.0.0.1", client=("127.0.0.1", 12345))
|
|
resp = client.get("/transformations/feed", headers={"host": "attacker.example"})
|
|
assert resp.status_code == 404, resp.text
|
|
|
|
|
|
def _client(*, loopback: bool) -> TestClient:
|
|
app = _make_app()
|
|
if loopback:
|
|
return TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345))
|
|
# Default TestClient presents client.host="testclient" — not loopback.
|
|
return TestClient(app)
|
|
|
|
|
|
def test_health_config_block_is_loopback_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""/health stays reachable for monitors but hides the `config` block (which
|
|
echoes upstream API URLs + backend settings) from non-loopback callers."""
|
|
monkeypatch.setenv("HEADROOM_SKIP_UPSTREAM_CHECK", "1")
|
|
|
|
network = _client(loopback=False).get("/health")
|
|
assert network.status_code == 200
|
|
assert "config" not in network.json()
|
|
# Basic health is still visible to monitors.
|
|
assert network.json()["status"] in {"healthy", "unhealthy"}
|
|
|
|
local = _client(loopback=True).get("/health")
|
|
assert local.status_code == 200
|
|
assert "config" in local.json()
|
|
|
|
|
|
def test_stats_per_request_metadata_is_loopback_only() -> None:
|
|
"""/stats keeps aggregate counters public but restricts per-request metadata
|
|
(recent_requests / request_logs) and `config` to loopback callers."""
|
|
network = _client(loopback=False).get("/stats")
|
|
assert network.status_code == 200
|
|
payload = network.json()
|
|
assert "tokens" in payload # aggregate counters still served
|
|
assert "recent_requests" not in payload
|
|
assert "request_logs" not in payload
|
|
assert "config" not in payload
|
|
|
|
local = _client(loopback=True).get("/stats").json()
|
|
assert "recent_requests" in local
|
|
assert "config" in local
|