mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
b99869778b
commit
bd55a426bc
6 changed files with 320 additions and 23 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
101
tests/test_proxy_cors.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
108
tests/test_proxy_loopback_gating.py
Normal file
108
tests/test_proxy_loopback_gating.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue