diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index e85f4ded8..f38b4aa6c 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -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"} diff --git a/tests/test_proxy/test_transformations_feed.py b/tests/test_proxy/test_transformations_feed.py index c7615a47f..280ad460d 100644 --- a/tests/test_proxy/test_transformations_feed.py +++ b/tests/test_proxy/test_transformations_feed.py @@ -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() diff --git a/tests/test_proxy_cors.py b/tests/test_proxy_cors.py new file mode 100644 index 000000000..384fc7876 --- /dev/null +++ b/tests/test_proxy_cors.py @@ -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" diff --git a/tests/test_proxy_healthchecks.py b/tests/test_proxy_healthchecks.py index 3d84d94d9..d6372aa63 100644 --- a/tests/test_proxy_healthchecks.py +++ b/tests/test_proxy_healthchecks.py @@ -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 diff --git a/tests/test_proxy_loopback_gating.py b/tests/test_proxy_loopback_gating.py new file mode 100644 index 000000000..b01e7e1e0 --- /dev/null +++ b/tests/test_proxy_loopback_gating.py @@ -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 diff --git a/tests/test_proxy_stats_recent_requests.py b/tests/test_proxy_stats_recent_requests.py index 6808ea743..a902a3894 100644 --- a/tests/test_proxy_stats_recent_requests.py +++ b/tests/test_proxy_stats_recent_requests.py @@ -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")