Skip to content

Commit bd55a42

Browse files
authored
fix(proxy): scope CORS to loopback + gate operator/content endpoints (headroomlabs-ai#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 headroomlabs-ai#863. Supersedes headroomlabs-ai#864 and headroomlabs-ai#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`:** - **headroomlabs-ai#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. - **headroomlabs-ai#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 headroomlabs-ai#758 is still valuable and would be welcome as a separate, rebased PR. Thanks to @gabiudrescu and @neogenix for the original analysis (headroomlabs-ai#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.
1 parent b998697 commit bd55a42

6 files changed

Lines changed: 320 additions & 23 deletions

File tree

headroom/proxy/server.py

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,6 +1661,27 @@ def _register_memory_components(proxy: HeadroomProxy, tracker: MemoryTracker) ->
16611661
# registered when the memory system is initialized with specific backends.
16621662

16631663

1664+
def _request_is_loopback(request: Request) -> bool:
1665+
"""Return True iff the caller is on loopback by *both* peer IP and Host header.
1666+
1667+
Mirrors the two-gate check in :func:`loopback_guard.require_loopback`
1668+
(loopback client IP + loopback ``Host`` header, the DNS-rebinding defence)
1669+
but returns a bool instead of raising. Endpoints use it to vary their
1670+
payload — serving sensitive sub-blocks (upstream URLs, per-request logs)
1671+
only to loopback callers — rather than 404ing network callers that still
1672+
have a legitimate use for the non-sensitive aggregate fields.
1673+
"""
1674+
from headroom.proxy.loopback_guard import is_loopback_host, is_loopback_host_header
1675+
1676+
client = getattr(request, "client", None)
1677+
client_host = getattr(client, "host", None) if client is not None else None
1678+
try:
1679+
host_header = request.headers.get("host")
1680+
except AttributeError:
1681+
host_header = None
1682+
return is_loopback_host(client_host) and is_loopback_host_header(host_header)
1683+
1684+
16641685
def create_app(config: ProxyConfig | None = None) -> FastAPI:
16651686
"""Create FastAPI application."""
16661687
if not FASTAPI_AVAILABLE:
@@ -2133,13 +2154,32 @@ async def _check_upstream() -> None:
21332154
_upstream_check_cache["error"] = str(exc)
21342155
_upstream_check_cache["expires_at"] = time.monotonic() + _UPSTREAM_CHECK_TTL
21352156

2136-
# CORS
2157+
# CORS: scoped to localhost by default. The old wildcard origin combined
2158+
# with allow_credentials=True let any web page the user had open read the
2159+
# proxy's content endpoints (e.g. /v1/retrieve returns raw, uncompressed
2160+
# tool outputs) via a cross-origin fetch to 127.0.0.1 (CWE-346).
2161+
#
2162+
# The default matches any loopback origin on any port via a regex, so it
2163+
# works regardless of the --port the proxy was started on without the app
2164+
# needing to know its own bound port (the port lives in the CLI/uvicorn
2165+
# layer, not in ProxyConfig). Set HEADROOM_CORS_ORIGINS (comma-separated)
2166+
# to pin an explicit allowlist for Docker or remote-dashboard deployments;
2167+
# "*" restores the old wildcard behaviour if the operator accepts the risk.
2168+
_default_loopback_origin_regex = r"https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?"
2169+
_cors_origins_env = os.environ.get("HEADROOM_CORS_ORIGINS", "").strip()
2170+
if _cors_origins_env:
2171+
_cors_allow_origins = [o.strip() for o in _cors_origins_env.split(",") if o.strip()]
2172+
_cors_allow_origin_regex: str | None = None
2173+
else:
2174+
_cors_allow_origins = []
2175+
_cors_allow_origin_regex = _default_loopback_origin_regex
21372176
app.add_middleware(
21382177
CORSMiddleware,
2139-
allow_origins=["*"],
2140-
allow_credentials=True,
2141-
allow_methods=["*"],
2142-
allow_headers=["*"],
2178+
allow_origins=_cors_allow_origins,
2179+
allow_origin_regex=_cors_allow_origin_regex,
2180+
allow_credentials=False,
2181+
allow_methods=["GET", "POST"],
2182+
allow_headers=["Content-Type", "Authorization"],
21432183
)
21442184

21452185
# X-Headroom-Stack: SDK adapters (TS openai/anthropic/etc.) tag their
@@ -2282,9 +2322,14 @@ async def readyz():
22822322
return JSONResponse(status_code=200 if payload["ready"] else 503, content=payload)
22832323

22842324
@app.get("/health")
2285-
async def health():
2325+
async def health(request: Request):
22862326
await _check_upstream()
2287-
payload = _health_payload(include_config=True)
2327+
# /health echoes upstream API URLs + backend config (the `config`
2328+
# block). That is operational detail an external scanner should not
2329+
# see, so include it only for loopback callers; network callers get the
2330+
# same body as /readyz (status + checks, no config). /livez and /readyz
2331+
# remain the unauthenticated probes for orchestration health.
2332+
payload = _health_payload(include_config=_request_is_loopback(request))
22882333
return JSONResponse(status_code=200, content=payload)
22892334

22902335
# Loopback-only debug introspection (Unit 5). A remote IP gets 404 —
@@ -2953,7 +2998,7 @@ async def _get_cached_stats_payload() -> dict[str, Any]:
29532998
return payload
29542999

29553000
@app.get("/stats")
2956-
async def stats(cached: bool = False):
3001+
async def stats(request: Request, cached: bool = False):
29573002
"""Get comprehensive proxy statistics.
29583003
29593004
This is the main stats endpoint - it aggregates data from all subsystems:
@@ -2967,14 +3012,27 @@ async def stats(cached: bool = False):
29673012
29683013
Use ``?cached=1`` for the dashboard fast path. That returns a short-TTL
29693014
snapshot to avoid rebuilding the full payload on every UI poll.
3015+
3016+
``recent_requests`` / ``request_logs`` (per-request ids, providers,
3017+
models, errors) and ``config`` (backend + savings profile) are embedded
3018+
only for loopback callers — the local dashboard. Network callers still
3019+
get the aggregate counters but never the per-request metadata.
29703020
"""
3021+
include_sensitive = _request_is_loopback(request)
29713022
if cached:
29723023
payload = dict(await _get_cached_stats_payload())
2973-
payload.update(_build_recent_request_payload())
2974-
payload["config"] = _dashboard_config_payload()
2975-
return payload
2976-
payload = await _build_stats_payload()
2977-
payload["config"] = _dashboard_config_payload()
3024+
if include_sensitive:
3025+
# Refresh the per-request tail on top of the cached snapshot.
3026+
payload.update(_build_recent_request_payload())
3027+
payload["config"] = _dashboard_config_payload()
3028+
else:
3029+
payload = await _build_stats_payload()
3030+
if include_sensitive:
3031+
payload["config"] = _dashboard_config_payload()
3032+
if not include_sensitive:
3033+
# _build_stats_payload bakes these in; strip for network callers.
3034+
payload.pop("recent_requests", None)
3035+
payload.pop("request_logs", None)
29783036
return payload
29793037

29803038
@app.post("/stats/reset", dependencies=[Depends(_require_loopback)])
@@ -3006,10 +3064,18 @@ async def stats_history(
30063064

30073065
return proxy.metrics.savings_tracker.history_response(history_mode=history_mode)
30083066

3009-
@app.get("/transformations/feed")
3067+
@app.get("/transformations/feed", dependencies=[Depends(_require_loopback)])
30103068
async def transformations_feed(limit: int = 20):
30113069
"""Get recent message transformations for the live feed.
30123070
3071+
Loopback-only: when ``log_full_messages`` is enabled this returns the
3072+
full request/response message bodies (prompt content and completions)
3073+
via ``request_messages`` / ``compressed_messages`` / ``response_content``.
3074+
With the default ``--host 0.0.0.0`` Docker bind, leaving it open would
3075+
expose chat history to anyone able to reach the proxy port. The
3076+
dashboard runs in the user's browser on loopback, so this gate does not
3077+
break legitimate use.
3078+
30133079
Returns empty list if log_full_messages is disabled (messages are not stored).
30143080
"""
30153081
if limit > 100:
@@ -3103,9 +3169,16 @@ async def debug_memory():
31033169
report = tracker.get_report()
31043170
return report.to_dict()
31053171

3106-
@app.post("/cache/clear")
3172+
@app.post("/cache/clear", dependencies=[Depends(_require_loopback)])
31073173
async def clear_cache():
3108-
"""Clear the response cache."""
3174+
"""Clear the response cache.
3175+
3176+
Loopback-only: this mutates server state. With the default
3177+
``--host 0.0.0.0`` Docker bind, an unauthenticated POST from any
3178+
network-reachable client would otherwise let them forcibly evict the
3179+
proxy's cached completions — a denial-of-service / cost-amplification
3180+
lever (every cleared entry forces a fresh upstream call).
3181+
"""
31093182
if proxy.cache:
31103183
await proxy.cache.clear()
31113184
return {"status": "cleared"}

tests/test_proxy/test_transformations_feed.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ def app():
1818
@pytest.mark.asyncio
1919
async def test_transformations_feed_endpoint_returns_list(app):
2020
"""The endpoint should return a list of recent transformations."""
21-
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
21+
async with AsyncClient(
22+
transport=ASGITransport(app=app, client=("127.0.0.1", 12345)),
23+
base_url="http://127.0.0.1",
24+
) as client:
2225
response = await client.get("/transformations/feed")
2326

2427
assert response.status_code == 200
@@ -36,7 +39,10 @@ async def test_transformations_feed_returns_messages(app):
3639
The pre/post pair is what makes compression legible: consumers can diff
3740
the two to see what the pipeline stripped, replaced, or kept.
3841
"""
39-
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
42+
async with AsyncClient(
43+
transport=ASGITransport(app=app, client=("127.0.0.1", 12345)),
44+
base_url="http://127.0.0.1",
45+
) as client:
4046
response = await client.get("/transformations/feed")
4147

4248
data = response.json()
@@ -53,7 +59,10 @@ async def test_transformations_feed_returns_messages(app):
5359
@pytest.mark.asyncio
5460
async def test_transformations_feed_respects_limit(app):
5561
"""The endpoint should respect a ?limit= query parameter."""
56-
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
62+
async with AsyncClient(
63+
transport=ASGITransport(app=app, client=("127.0.0.1", 12345)),
64+
base_url="http://127.0.0.1",
65+
) as client:
5766
response = await client.get("/transformations/feed?limit=5")
5867

5968
data = response.json()

tests/test_proxy_cors.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""CORS scoping tests.
2+
3+
The proxy binds on localhost and serves content endpoints (e.g. ``/v1/retrieve``
4+
returns raw, uncompressed tool outputs). A wildcard CORS origin combined with
5+
``allow_credentials=True`` let any web page the user had open read those
6+
responses via a cross-origin fetch to ``127.0.0.1`` (CWE-346). The default
7+
policy must allow only loopback origins — on *any* port, since the bound port
8+
lives in the CLI/uvicorn layer, not in ``ProxyConfig`` — while still offering an
9+
explicit override for Docker / remote-dashboard deployments. See #863 / #864.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import httpx
15+
import pytest
16+
from fastapi.testclient import TestClient
17+
18+
from headroom.proxy.server import ProxyConfig, create_app
19+
20+
21+
def _make_client() -> TestClient:
22+
config = ProxyConfig(
23+
optimize=False,
24+
cache_enabled=False,
25+
rate_limit_enabled=False,
26+
cost_tracking_enabled=False,
27+
log_requests=False,
28+
ccr_inject_tool=False,
29+
ccr_handle_responses=False,
30+
ccr_context_tracking=False,
31+
image_optimize=False,
32+
)
33+
return TestClient(create_app(config))
34+
35+
36+
def _preflight(client: TestClient, origin: str) -> httpx.Response:
37+
"""Send a CORS preflight; CORSMiddleware answers it directly."""
38+
return client.options(
39+
"/v1/messages",
40+
headers={"Origin": origin, "Access-Control-Request-Method": "POST"},
41+
)
42+
43+
44+
@pytest.mark.parametrize(
45+
"origin",
46+
[
47+
"http://localhost:8787",
48+
"http://127.0.0.1:8787",
49+
"http://localhost:9000", # non-default port — must still be allowed
50+
"http://127.0.0.1:54321",
51+
"https://localhost:8787",
52+
"http://[::1]:8787", # IPv6 loopback
53+
"http://localhost", # no explicit port
54+
],
55+
)
56+
def test_loopback_origins_allowed_on_any_port(monkeypatch: pytest.MonkeyPatch, origin: str) -> None:
57+
monkeypatch.delenv("HEADROOM_CORS_ORIGINS", raising=False)
58+
resp = _preflight(_make_client(), origin)
59+
assert resp.status_code == 200, resp.text
60+
assert resp.headers.get("access-control-allow-origin") == origin
61+
# The original vulnerability was wildcard + credentials; credentials stay off.
62+
assert resp.headers.get("access-control-allow-credentials") != "true"
63+
64+
65+
@pytest.mark.parametrize(
66+
"origin",
67+
[
68+
"http://evil.com",
69+
"https://attacker.example",
70+
"http://localhost.evil.com", # suffix smuggling
71+
"http://127.0.0.1.evil.com",
72+
"http://notlocalhost", # prefix smuggling
73+
],
74+
)
75+
def test_cross_origin_pages_rejected(monkeypatch: pytest.MonkeyPatch, origin: str) -> None:
76+
monkeypatch.delenv("HEADROOM_CORS_ORIGINS", raising=False)
77+
resp = _preflight(_make_client(), origin)
78+
# A disallowed origin is never echoed back, so the browser blocks the read.
79+
assert resp.headers.get("access-control-allow-origin") != origin
80+
81+
82+
def test_explicit_allowlist_override(monkeypatch: pytest.MonkeyPatch) -> None:
83+
monkeypatch.setenv("HEADROOM_CORS_ORIGINS", "https://dash.example.com, http://10.0.0.5:3000")
84+
client = _make_client()
85+
86+
allowed = _preflight(client, "https://dash.example.com")
87+
assert allowed.status_code == 200
88+
assert allowed.headers.get("access-control-allow-origin") == "https://dash.example.com"
89+
90+
# Once an explicit list is set, loopback is no longer implicitly trusted.
91+
blocked = _preflight(client, "http://localhost:8787")
92+
assert blocked.headers.get("access-control-allow-origin") != "http://localhost:8787"
93+
94+
95+
def test_wildcard_optback(monkeypatch: pytest.MonkeyPatch) -> None:
96+
monkeypatch.setenv("HEADROOM_CORS_ORIGINS", "*")
97+
resp = _preflight(_make_client(), "http://evil.com")
98+
assert resp.status_code == 200
99+
assert resp.headers.get("access-control-allow-origin") == "*"
100+
# Wildcard opt-back must not silently re-enable credentialed reads.
101+
assert resp.headers.get("access-control-allow-credentials") != "true"

tests/test_proxy_healthchecks.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def client(monkeypatch):
2323
cost_tracking_enabled=False,
2424
)
2525
app = create_app(config)
26-
with TestClient(app) as test_client:
26+
# Loopback client/Host: /health serves the `config` block only to loopback
27+
# callers (network callers get the /readyz-shape body, no config).
28+
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as test_client:
2729
yield test_client
2830

2931

@@ -105,7 +107,7 @@ def test_health_reports_agent_savings_config():
105107
)
106108
app = create_app(config)
107109

108-
with TestClient(app) as client:
110+
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as client:
109111
response = client.get("/health")
110112

111113
assert response.status_code == 200

0 commit comments

Comments
 (0)