fix(proxy): scope CORS to loopback + gate operator/content endpoints (#1226)

## Description

Locks down the proxy's browser- and network-facing attack surface, which
matters most under a `--host 0.0.0.0` bind (the Docker default). The
wildcard CORS policy (`allow_origins=["*"]` + `allow_credentials=True`)
let any web page the user had open read the proxy's content endpoints —
`/v1/retrieve` returns raw, uncompressed tool outputs (source, secrets)
— via a cross-origin fetch to `127.0.0.1` (CWE-346). Several operator
endpoints additionally leaked sensitive data or allowed unauthenticated
state mutation to any network-reachable client.

This PR scopes CORS to loopback origins and extends the project's
existing `require_loopback` trust boundary (already used for `/admin/*`
and `/debug/*`) to the remaining exposed endpoints.

Closes #863.

Supersedes #864 and #758 — see "Additional Notes".

## 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

- **CORS**: replaced `allow_origins=["*"]` + `allow_credentials=True`
with a port-agnostic loopback origin regex
(`https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?`),
`allow_credentials=False`, and methods/headers narrowed to `GET/POST` +
`Content-Type/Authorization`. `HEADROOM_CORS_ORIGINS` (comma-separated)
pins an explicit allowlist for Docker/remote dashboards; `*` opts back
into the old wildcard.
- **`/transformations/feed`** and **`/cache/clear`** gated behind
`require_loopback` → 404 for non-loopback callers. The feed returns full
prompt/completion bodies when `log_full_messages` is on; `/cache/clear`
is unauthenticated state mutation (cache-eviction DoS / cost
amplification).
- **`/health`**: the `config` block (upstream API URLs, savings profile)
is now served only to loopback callers; network callers get the
`/readyz`-shape body (status/checks). `/livez` and `/readyz` remain
unauthenticated probes for orchestration.
- **`/stats`**: `recent_requests` / `request_logs` (per-request ids,
providers, models, errors) and `config` are served only to loopback
callers; aggregate counters stay public for remote monitoring.
- Added `_request_is_loopback()` helper mirroring `require_loopback`'s
two-gate check (loopback peer IP + loopback `Host` header, the
DNS-rebinding defence) but degrading the payload instead of returning
404, so monitors keep the non-sensitive fields.
- Tests: new `tests/test_proxy_cors.py` and
`tests/test_proxy_loopback_gating.py`; updated 4 existing tests that
assert the now-loopback-only data to use loopback clients.

## 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
$ ruff check headroom/proxy/server.py tests/test_proxy_cors.py \
    tests/test_proxy_loopback_gating.py tests/test_proxy_healthchecks.py \
    tests/test_proxy_stats_recent_requests.py tests/test_proxy/test_transformations_feed.py
All checks passed!

$ mypy headroom --ignore-missing-imports
Success: no issues found in 380 source files

$ pytest tests/test_proxy_cors.py tests/test_proxy_loopback_gating.py \
    tests/test_proxy/test_transformations_feed.py tests/test_proxy_healthchecks.py \
    tests/test_proxy_stats_recent_requests.py tests/test_proxy_dashboard_stats_cache.py \
    tests/test_proxy_compression_executor.py tests/test_header_isolation.py -q
======================== 82 passed, 1 skipped in 17.75s ========================
```

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12 venv; FastAPI
`TestClient` driving the real `create_app()` ASGI app
- Exact command / steps: issued requests as a non-loopback caller
(`client.host=testclient`) vs a loopback caller
(`base_url=http://127.0.0.1`, `client=("127.0.0.1", 9999)`), plus CORS
preflights with varying `Origin` headers
- Observed result: CORS — `http://evil.com` → no
`access-control-allow-origin`; `http://localhost:8787` and
`http://localhost:9000` → echoed (loopback allowed on any port);
`access-control-allow-credentials` → absent. `/cache/clear` and
`/transformations/feed` → 404 (network) / 200 (loopback). `/health`
`config` block present for loopback only. `/stats` `recent_requests`
present for loopback only, while the aggregate `tokens` block stays
present for network callers.
- Not tested: a live `headroom proxy` process bound on `0.0.0.0` reached
from a second host (simulated via ASGI peer/Host instead); end-to-end
browser DNS-rebinding (covered by the `Host`-header gate and its unit
test)

## 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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — proxy/middleware change; behavior is captured under "Real Behavior
Proof".

## Additional Notes

**Supersedes two stale PRs that target the same issue but have drifted
from `main`:**

- **#864** (`fix(proxy): scope CORS to localhost`, @gabiudrescu) —
correct instinct and the source of the tighter `GET/POST` +
`Content-Type/Authorization` scoping kept here, but it derived the
allowlist from the `HEADROOM_PORT` env var (wrong when `--port` is
passed as a CLI flag), carried ~40 lines of unrelated punctuation churn,
and is ~125 commits behind `main`. The port-agnostic regex used here
resolves the reviewer's port concern.
- **#758** (`security: adversarial review`, @neogenix) — bundled these
same application-layer fixes with a large CI/CD + Docker supply-chain
pass. It is a ~160-commit-behind draft whose `server.py` no longer
merges cleanly (`main` independently adopted the same `require_loopback`
pattern). The application-layer fixes are rebased onto current `main`
here; the CI/Docker/supply-chain hardening from #758 is still valuable
and would be welcome as a separate, rebased PR.

Thanks to @gabiudrescu and @neogenix for the original analysis (#863).

**Deliberate scope / follow-ups (not in this PR):**
- `/stats` aggregate counters and the basic `/health` body remain
readable on a `0.0.0.0` bind by design, so remote monitoring keeps
working. Full lock-down is a one-line `Depends(require_loopback)` each
if preferred.
- The `/v1/retrieve*` family stays network-reachable; it can't be
loopback-gated without breaking legitimate remote/containerized agents
and needs auth instead — tracked separately.
- `ruff check .` is scoped to changed paths above because the dashboard
HTML template trips ruff's `invalid-syntax` (a known repo
false-positive); `mypy` is run over the full `headroom` package.
This commit is contained in:
Tejas Chopra 2026-06-21 00:50:55 -07:00 committed by GitHub
parent b99869778b
commit bd55a426bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 320 additions and 23 deletions

View file

@ -1661,6 +1661,27 @@ def _register_memory_components(proxy: HeadroomProxy, tracker: MemoryTracker) ->
# registered when the memory system is initialized with specific backends.
def _request_is_loopback(request: Request) -> bool:
"""Return True iff the caller is on loopback by *both* peer IP and Host header.
Mirrors the two-gate check in :func:`loopback_guard.require_loopback`
(loopback client IP + loopback ``Host`` header, the DNS-rebinding defence)
but returns a bool instead of raising. Endpoints use it to vary their
payload serving sensitive sub-blocks (upstream URLs, per-request logs)
only to loopback callers rather than 404ing network callers that still
have a legitimate use for the non-sensitive aggregate fields.
"""
from headroom.proxy.loopback_guard import is_loopback_host, is_loopback_host_header
client = getattr(request, "client", None)
client_host = getattr(client, "host", None) if client is not None else None
try:
host_header = request.headers.get("host")
except AttributeError:
host_header = None
return is_loopback_host(client_host) and is_loopback_host_header(host_header)
def create_app(config: ProxyConfig | None = None) -> FastAPI:
"""Create FastAPI application."""
if not FASTAPI_AVAILABLE:
@ -2133,13 +2154,32 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
_upstream_check_cache["error"] = str(exc)
_upstream_check_cache["expires_at"] = time.monotonic() + _UPSTREAM_CHECK_TTL
# CORS
# CORS: scoped to localhost by default. The old wildcard origin combined
# with allow_credentials=True let any web page the user had open read the
# proxy's content endpoints (e.g. /v1/retrieve returns raw, uncompressed
# tool outputs) via a cross-origin fetch to 127.0.0.1 (CWE-346).
#
# The default matches any loopback origin on any port via a regex, so it
# works regardless of the --port the proxy was started on without the app
# needing to know its own bound port (the port lives in the CLI/uvicorn
# layer, not in ProxyConfig). Set HEADROOM_CORS_ORIGINS (comma-separated)
# to pin an explicit allowlist for Docker or remote-dashboard deployments;
# "*" restores the old wildcard behaviour if the operator accepts the risk.
_default_loopback_origin_regex = r"https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?"
_cors_origins_env = os.environ.get("HEADROOM_CORS_ORIGINS", "").strip()
if _cors_origins_env:
_cors_allow_origins = [o.strip() for o in _cors_origins_env.split(",") if o.strip()]
_cors_allow_origin_regex: str | None = None
else:
_cors_allow_origins = []
_cors_allow_origin_regex = _default_loopback_origin_regex
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_origins=_cors_allow_origins,
allow_origin_regex=_cors_allow_origin_regex,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type", "Authorization"],
)
# X-Headroom-Stack: SDK adapters (TS openai/anthropic/etc.) tag their
@ -2282,9 +2322,14 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
return JSONResponse(status_code=200 if payload["ready"] else 503, content=payload)
@app.get("/health")
async def health():
async def health(request: Request):
await _check_upstream()
payload = _health_payload(include_config=True)
# /health echoes upstream API URLs + backend config (the `config`
# block). That is operational detail an external scanner should not
# see, so include it only for loopback callers; network callers get the
# same body as /readyz (status + checks, no config). /livez and /readyz
# remain the unauthenticated probes for orchestration health.
payload = _health_payload(include_config=_request_is_loopback(request))
return JSONResponse(status_code=200, content=payload)
# Loopback-only debug introspection (Unit 5). A remote IP gets 404 —
@ -2953,7 +2998,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
return payload
@app.get("/stats")
async def stats(cached: bool = False):
async def stats(request: Request, cached: bool = False):
"""Get comprehensive proxy statistics.
This is the main stats endpoint - it aggregates data from all subsystems:
@ -2967,14 +3012,27 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
Use ``?cached=1`` for the dashboard fast path. That returns a short-TTL
snapshot to avoid rebuilding the full payload on every UI poll.
``recent_requests`` / ``request_logs`` (per-request ids, providers,
models, errors) and ``config`` (backend + savings profile) are embedded
only for loopback callers the local dashboard. Network callers still
get the aggregate counters but never the per-request metadata.
"""
include_sensitive = _request_is_loopback(request)
if cached:
payload = dict(await _get_cached_stats_payload())
payload.update(_build_recent_request_payload())
payload["config"] = _dashboard_config_payload()
return payload
payload = await _build_stats_payload()
payload["config"] = _dashboard_config_payload()
if include_sensitive:
# Refresh the per-request tail on top of the cached snapshot.
payload.update(_build_recent_request_payload())
payload["config"] = _dashboard_config_payload()
else:
payload = await _build_stats_payload()
if include_sensitive:
payload["config"] = _dashboard_config_payload()
if not include_sensitive:
# _build_stats_payload bakes these in; strip for network callers.
payload.pop("recent_requests", None)
payload.pop("request_logs", None)
return payload
@app.post("/stats/reset", dependencies=[Depends(_require_loopback)])
@ -3006,10 +3064,18 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
return proxy.metrics.savings_tracker.history_response(history_mode=history_mode)
@app.get("/transformations/feed")
@app.get("/transformations/feed", dependencies=[Depends(_require_loopback)])
async def transformations_feed(limit: int = 20):
"""Get recent message transformations for the live feed.
Loopback-only: when ``log_full_messages`` is enabled this returns the
full request/response message bodies (prompt content and completions)
via ``request_messages`` / ``compressed_messages`` / ``response_content``.
With the default ``--host 0.0.0.0`` Docker bind, leaving it open would
expose chat history to anyone able to reach the proxy port. The
dashboard runs in the user's browser on loopback, so this gate does not
break legitimate use.
Returns empty list if log_full_messages is disabled (messages are not stored).
"""
if limit > 100:
@ -3103,9 +3169,16 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
report = tracker.get_report()
return report.to_dict()
@app.post("/cache/clear")
@app.post("/cache/clear", dependencies=[Depends(_require_loopback)])
async def clear_cache():
"""Clear the response cache."""
"""Clear the response cache.
Loopback-only: this mutates server state. With the default
``--host 0.0.0.0`` Docker bind, an unauthenticated POST from any
network-reachable client would otherwise let them forcibly evict the
proxy's cached completions — a denial-of-service / cost-amplification
lever (every cleared entry forces a fresh upstream call).
"""
if proxy.cache:
await proxy.cache.clear()
return {"status": "cleared"}

View file

@ -18,7 +18,10 @@ def app():
@pytest.mark.asyncio
async def test_transformations_feed_endpoint_returns_list(app):
"""The endpoint should return a list of recent transformations."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
async with AsyncClient(
transport=ASGITransport(app=app, client=("127.0.0.1", 12345)),
base_url="http://127.0.0.1",
) as client:
response = await client.get("/transformations/feed")
assert response.status_code == 200
@ -36,7 +39,10 @@ async def test_transformations_feed_returns_messages(app):
The pre/post pair is what makes compression legible: consumers can diff
the two to see what the pipeline stripped, replaced, or kept.
"""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
async with AsyncClient(
transport=ASGITransport(app=app, client=("127.0.0.1", 12345)),
base_url="http://127.0.0.1",
) as client:
response = await client.get("/transformations/feed")
data = response.json()
@ -53,7 +59,10 @@ async def test_transformations_feed_returns_messages(app):
@pytest.mark.asyncio
async def test_transformations_feed_respects_limit(app):
"""The endpoint should respect a ?limit= query parameter."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
async with AsyncClient(
transport=ASGITransport(app=app, client=("127.0.0.1", 12345)),
base_url="http://127.0.0.1",
) as client:
response = await client.get("/transformations/feed?limit=5")
data = response.json()

101
tests/test_proxy_cors.py Normal file
View file

@ -0,0 +1,101 @@
"""CORS scoping tests.
The proxy binds on localhost and serves content endpoints (e.g. ``/v1/retrieve``
returns raw, uncompressed tool outputs). A wildcard CORS origin combined with
``allow_credentials=True`` let any web page the user had open read those
responses via a cross-origin fetch to ``127.0.0.1`` (CWE-346). The default
policy must allow only loopback origins on *any* port, since the bound port
lives in the CLI/uvicorn layer, not in ``ProxyConfig`` while still offering an
explicit override for Docker / remote-dashboard deployments. See #863 / #864.
"""
from __future__ import annotations
import httpx
import pytest
from fastapi.testclient import TestClient
from headroom.proxy.server import ProxyConfig, create_app
def _make_client() -> TestClient:
config = 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,
)
return TestClient(create_app(config))
def _preflight(client: TestClient, origin: str) -> httpx.Response:
"""Send a CORS preflight; CORSMiddleware answers it directly."""
return client.options(
"/v1/messages",
headers={"Origin": origin, "Access-Control-Request-Method": "POST"},
)
@pytest.mark.parametrize(
"origin",
[
"http://localhost:8787",
"http://127.0.0.1:8787",
"http://localhost:9000", # non-default port — must still be allowed
"http://127.0.0.1:54321",
"https://localhost:8787",
"http://[::1]:8787", # IPv6 loopback
"http://localhost", # no explicit port
],
)
def test_loopback_origins_allowed_on_any_port(monkeypatch: pytest.MonkeyPatch, origin: str) -> None:
monkeypatch.delenv("HEADROOM_CORS_ORIGINS", raising=False)
resp = _preflight(_make_client(), origin)
assert resp.status_code == 200, resp.text
assert resp.headers.get("access-control-allow-origin") == origin
# The original vulnerability was wildcard + credentials; credentials stay off.
assert resp.headers.get("access-control-allow-credentials") != "true"
@pytest.mark.parametrize(
"origin",
[
"http://evil.com",
"https://attacker.example",
"http://localhost.evil.com", # suffix smuggling
"http://127.0.0.1.evil.com",
"http://notlocalhost", # prefix smuggling
],
)
def test_cross_origin_pages_rejected(monkeypatch: pytest.MonkeyPatch, origin: str) -> None:
monkeypatch.delenv("HEADROOM_CORS_ORIGINS", raising=False)
resp = _preflight(_make_client(), origin)
# A disallowed origin is never echoed back, so the browser blocks the read.
assert resp.headers.get("access-control-allow-origin") != origin
def test_explicit_allowlist_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HEADROOM_CORS_ORIGINS", "https://dash.example.com, http://10.0.0.5:3000")
client = _make_client()
allowed = _preflight(client, "https://dash.example.com")
assert allowed.status_code == 200
assert allowed.headers.get("access-control-allow-origin") == "https://dash.example.com"
# Once an explicit list is set, loopback is no longer implicitly trusted.
blocked = _preflight(client, "http://localhost:8787")
assert blocked.headers.get("access-control-allow-origin") != "http://localhost:8787"
def test_wildcard_optback(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HEADROOM_CORS_ORIGINS", "*")
resp = _preflight(_make_client(), "http://evil.com")
assert resp.status_code == 200
assert resp.headers.get("access-control-allow-origin") == "*"
# Wildcard opt-back must not silently re-enable credentialed reads.
assert resp.headers.get("access-control-allow-credentials") != "true"

View file

@ -23,7 +23,9 @@ def client(monkeypatch):
cost_tracking_enabled=False,
)
app = create_app(config)
with TestClient(app) as test_client:
# Loopback client/Host: /health serves the `config` block only to loopback
# callers (network callers get the /readyz-shape body, no config).
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as test_client:
yield test_client
@ -105,7 +107,7 @@ def test_health_reports_agent_savings_config():
)
app = create_app(config)
with TestClient(app) as client:
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as client:
response = client.get("/health")
assert response.status_code == 200

View file

@ -0,0 +1,108 @@
"""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
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

View file

@ -69,7 +69,8 @@ def test_stats_refreshes_recent_requests_when_cached() -> None:
}
)
with TestClient(app) as client:
# Loopback client/Host: recent_requests is served only to loopback callers.
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as client:
logger.logs = [first_log]
first_response = client.get("/stats?cached=1")
assert first_response.status_code == 200
@ -154,7 +155,10 @@ def test_stats_preserves_default_smart_crusher_compaction_state() -> None:
rate_limit_enabled=False,
cost_tracking_enabled=False,
)
client = TestClient(create_app(config))
# Loopback client/Host: the `config` block is served only to loopback callers.
client = TestClient(
create_app(config), base_url="http://127.0.0.1", client=("127.0.0.1", 12345)
)
response = client.get("/stats")