From bd55a426bc3ec6cd3e0ad46cd3182209afb84937 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Sun, 21 Jun 2026 00:50:55 -0700 Subject: [PATCH] fix(proxy): scope CORS to loopback + gate operator/content endpoints (#1226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- headroom/proxy/server.py | 105 ++++++++++++++--- tests/test_proxy/test_transformations_feed.py | 15 ++- tests/test_proxy_cors.py | 101 ++++++++++++++++ tests/test_proxy_healthchecks.py | 6 +- tests/test_proxy_loopback_gating.py | 108 ++++++++++++++++++ tests/test_proxy_stats_recent_requests.py | 8 +- 6 files changed, 320 insertions(+), 23 deletions(-) create mode 100644 tests/test_proxy_cors.py create mode 100644 tests/test_proxy_loopback_gating.py 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")