headroom/tests/test_proxy_loopback_gating.py
inix acafb2d0f6
fix(proxy): gate CCR retrieve/compress endpoints to loopback (#1338)
## 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.
2026-06-24 09:45:20 -05:00

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