diff --git a/SECURITY.md b/SECURITY.md index fa2235c..faae61c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,6 +17,15 @@ directly, bypassing Cerberus policy, so scope and rotate it as you would any provider key. An unpublished container port alone does not protect against other containers on the same network or a privileged host operator. +Loopback peers are trusted as admins, and without `server.api_token_env` they may +use the inference surface. That trust also requires the request's `Host` to name +this machine (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`, or a name listed in +`server.trusted_hosts`), which defeats DNS rebinding from a local browser. Do not +put a reverse proxy on the same host in front of a bare `cerberus serve` that +forwards to 127.0.0.1 with a loopback `Host`: every proxied request would then be +a loopback peer. Configure admin SSO or tokens and have the proxy pass the public +`Host` through. + Admin sessions and pending OIDC logins contain identity and authentication material. Restrict state-volume access and protect backups. See `docs/persistence.md` for supported deployment limits and recovery behavior. diff --git a/src/cerberus/app.py b/src/cerberus/app.py index 203d780..13b3bcc 100644 --- a/src/cerberus/app.py +++ b/src/cerberus/app.py @@ -79,6 +79,27 @@ def _release_id() -> str: _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) + +def _host_name(header: str) -> str: + """The hostname from a Host header, lowercased, without port or brackets.""" + + value = header.strip().lower() + if value.startswith("["): + return value[1:].split("]", 1)[0] + return value.rsplit(":", 1)[0] if value.count(":") == 1 else value + + +def is_local_host_header(header: str | None, trusted: frozenset[str] = frozenset()) -> bool: + """Whether a Host header names this machine (or an operator-trusted name).""" + + if not header: + return False + name = _host_name(header) + if name in _LOOPBACK_HOSTS or name in trusted or name.endswith(".localhost"): + return True + parts = name.split(".") + return len(parts) == 4 and parts[0] == "127" and all(p.isdigit() and int(p) < 256 for p in parts) + # The audit read is bounded: the table is lifetime history and a console asking # for all of it would grow without limit. No cursor yet — a bounded newest-first # window is what the screen needs, and pagination can be designed when something @@ -296,11 +317,17 @@ def token_matches(token_env: str | None, request: Request) -> bool: # non-ASCII header input is a bad credential, never a server error return False + trusted_hosts = frozenset(name.lower() for name in boot_config.server.trusted_hosts) + + def local_host(request: Request) -> bool: + return is_local_host_header(request.headers.get("host"), trusted_hosts) + def authenticated(request: Request) -> bool: - """Inference-surface gate: the api token, or open when none is configured.""" + """Inference-surface gate: the api token, or open to local clients when none + is configured (the schema only allows that on a loopback bind).""" if boot_config.server.api_token_env is None: - return True + return local_host(request) return token_matches(boot_config.server.api_token_env, request) @app.get("/", include_in_schema=False) @@ -349,8 +376,14 @@ async def health() -> dict[str, Any]: # -- admin surface (control plane) ------------------------------------ def is_loopback(request: Request) -> bool: - # transport peer address only — forwarding headers are client-forgeable - return request.client is not None and request.client.host in _LOOPBACK_HOSTS + # transport peer address only — forwarding headers are client-forgeable. + # The Host must name this machine too: under DNS rebinding a hostile page + # in a local browser reaches 127.0.0.1 with its own hostname as Host. + return ( + request.client is not None + and request.client.host in _LOOPBACK_HOSTS + and local_host(request) + ) def admin_denied(request: Request, *, read_only: bool = False) -> JSONResponse | None: """Loopback-only or admin-scoped credential (PLAN Session 9). diff --git a/src/cerberus/registry/schema.py b/src/cerberus/registry/schema.py index 2e184f1..692e29b 100644 --- a/src/cerberus/registry/schema.py +++ b/src/cerberus/registry/schema.py @@ -59,6 +59,13 @@ class ServerConfig(BaseModel): # only — never discovered — and empty by default, so the boundary does not # widen unless an operator says so. admin_config_roots: list[str] = Field(default_factory=list) + # Loopback trust (admin access without a credential, and the token-less + # inference surface) also requires the request's Host to name this machine: + # localhost, *.localhost, 127.0.0.0/8 or ::1. A DNS-rebinding page reaches + # 127.0.0.1 from the operator's browser under its own hostname, so the peer + # address alone cannot tell it from a local client. List any other name + # local clients use here. + trusted_hosts: list[str] = Field(default_factory=list) @model_validator(mode="after") def validate_credential_separation(self) -> "ServerConfig": diff --git a/tests/test_admin_candidates.py b/tests/test_admin_candidates.py index 86971d8..452f3aa 100644 --- a/tests/test_admin_candidates.py +++ b/tests/test_admin_candidates.py @@ -40,7 +40,7 @@ def build_app(tmp_path, **server): async def post(app, url, body): async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: return await client.post(url, json=body, headers=CSRF) @@ -131,7 +131,7 @@ async def test_staged_candidate_round_trips_through_validate_and_activate(tmp_pa app = build_app(tmp_path) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: staged = await client.post( "/admin/config/stage", json={"updates": {"providers.alpha.quota_cooldown_seconds": 1234}}, @@ -247,7 +247,7 @@ async def test_version_checksum_collision_keeps_its_cerberus_message(tmp_path): async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: await client.post("/admin/validate", json={"path": first}, headers=CSRF) clash = await client.post("/admin/validate", json={"path": collision}, headers=CSRF) diff --git a/tests/test_admin_credentials.py b/tests/test_admin_credentials.py index 2b2ceb6..1a9756e 100644 --- a/tests/test_admin_credentials.py +++ b/tests/test_admin_credentials.py @@ -95,7 +95,7 @@ async def test_validated_inference_token_still_denied_on_all_mutations(monkeypat app = make_tokened_app(monkeypatch, tmp_path) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app, client=REMOTE) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as remote: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as remote: for url in MUTATION_URLS: response = await remote.post(url, json={}, headers={"authorization": "Bearer inference-token"}) assert response.status_code == 403, url @@ -106,13 +106,13 @@ async def test_validated_admin_token_keeps_read_only_remote_and_loopback_mutatio app = make_tokened_app(monkeypatch, tmp_path) async with app.router.lifespan_context(app): remote_transport = httpx.ASGITransport(app=app, client=REMOTE) - async with httpx.AsyncClient(transport=remote_transport, base_url="http://test") as remote: + async with httpx.AsyncClient(transport=remote_transport, base_url="http://localhost") as remote: admin = {"authorization": "Bearer admin-secret"} assert (await remote.get("/admin/events", headers=admin)).status_code == 200 for url in MUTATION_URLS: assert (await remote.post(url, json={}, headers=admin)).status_code == 403, url local_transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)) - async with httpx.AsyncClient(transport=local_transport, base_url="http://test") as local: + async with httpx.AsyncClient(transport=local_transport, base_url="http://localhost") as local: # auth + CSRF ok, body invalid assert (await local.post("/admin/validate", json={}, headers=CSRF)).status_code == 400 diff --git a/tests/test_admin_fields.py b/tests/test_admin_fields.py index c33beb1..b452e32 100644 --- a/tests/test_admin_fields.py +++ b/tests/test_admin_fields.py @@ -93,7 +93,7 @@ async def test_config_schema_endpoint_is_read_only_gated(env, tmp_path): app = create_app(doc, http_transport=httpx.MockTransport(ok_upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: resp = await client.get("/admin/config/schema") assert resp.status_code == 200 keys = {f["key"] for f in resp.json()["fields"]} @@ -106,7 +106,7 @@ async def test_stage_endpoint_rejects_non_allow_listed_keys(env, tmp_path): app = create_app(doc, http_transport=httpx.MockTransport(ok_upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: bad_key = await client.post( "/admin/config/stage", json={"updates": {"providers.alpha.credentials.main.api_key_env": "EVIL"}}, @@ -127,7 +127,7 @@ async def test_stage_validate_activate_loop_changes_live_behavior(env, tmp_path) app = create_app(doc, http_transport=httpx.MockTransport(ok_upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: staged = await client.post( "/admin/config/stage", json={"updates": {"providers.alpha.quota_cooldown_seconds": "45"}}, diff --git a/tests/test_admin_ui.py b/tests/test_admin_ui.py index 2bf717b..cd51fb8 100644 --- a/tests/test_admin_ui.py +++ b/tests/test_admin_ui.py @@ -51,7 +51,7 @@ def make_app(monkeypatch, tmp_path, *, token: str | None = None, admin_token: st def client_for(app, client_addr): return httpx.AsyncClient( - transport=httpx.ASGITransport(app=app, client=client_addr), base_url="http://test" + transport=httpx.ASGITransport(app=app, client=client_addr), base_url="http://localhost" ) diff --git a/tests/test_app.py b/tests/test_app.py index 118506e..0c9aef9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -50,7 +50,7 @@ def make_config(monkeypatch: pytest.MonkeyPatch, **server) -> CerberusConfig: async def call(app, method: str, path: str, **kwargs) -> httpx.Response: async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: return await client.request(method, path, **kwargs) @@ -86,7 +86,7 @@ async def upstream(request: httpx.Request) -> httpx.Response: app = create_app(make_config(monkeypatch), http_transport=httpx.MockTransport(upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: response = await client.post("/v1/chat/completions", json={"model": "cerberus/main", "messages": []}) health = await client.get("/admin/health") # cooldowns are admin-only diagnostics @@ -211,7 +211,7 @@ async def test_external_bind_rejects_unauthenticated_requests(monkeypatch): app = create_app(config, http_transport=httpx.MockTransport(lambda _r: httpx.Response(200, json={}))) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: unauthorized = await client.get("/v1/models") authorized = await client.get("/v1/models", headers={"authorization": "Bearer router-token"}) non_ascii = await client.get( @@ -384,7 +384,7 @@ async def upstream(request: httpx.Request) -> httpx.Response: app = create_app(make_config(monkeypatch), http_transport=httpx.MockTransport(upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: await client.post("/v1/chat/completions", json={"model": "cerberus/main", "messages": []}) public = await client.get("/health") admin = await client.get("/admin/health") diff --git a/tests/test_audit.py b/tests/test_audit.py index 095011c..a2ec26d 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -63,7 +63,7 @@ def build(path): def client_for(app, addr=LOOPBACK): return httpx.AsyncClient( - transport=httpx.ASGITransport(app=app, client=addr), base_url="http://test" + transport=httpx.ASGITransport(app=app, client=addr), base_url="http://localhost" ) diff --git a/tests/test_control.py b/tests/test_control.py index effcb0c..66a5548 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -68,7 +68,7 @@ async def test_validate_reports_version_and_rejects_bad_configs(env, tmp_path): app = create_app(doc, http_transport=httpx.MockTransport(ok_upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: ok = await client.post("/admin/validate", json={"path": good}, headers=CSRF) broken = await client.post("/admin/validate", json={"path": bad}, headers=CSRF) @@ -91,7 +91,7 @@ def upstream(request: httpx.Request) -> httpx.Response: app = create_app(load_config_document(v1), http_transport=httpx.MockTransport(upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: body = {"model": "cerberus/free", "messages": []} await client.post("/v1/chat/completions", json=body) activated = await client.post("/admin/activate", json={"path": v2}, headers=CSRF) @@ -121,7 +121,7 @@ async def test_version_cannot_be_rebound_to_a_different_checksum(env, tmp_path): async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: validated = await client.post("/admin/validate", json={"path": collision_path}, headers=CSRF) shadowed = await client.post("/admin/shadow", json={"path": collision_path}, headers=CSRF) activated = await client.post("/admin/activate", json={"path": collision_path}, headers=CSRF) @@ -149,7 +149,7 @@ async def test_rolled_back_version_binding_remains_reserved(env, tmp_path): async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: assert (await client.post("/admin/activate", json={"path": v2}, headers=CSRF)).status_code == 200 assert (await client.post("/admin/rollback", headers=CSRF)).status_code == 200 rebound = await client.post("/admin/activate", json={"path": v2_collision}, headers=CSRF) @@ -164,7 +164,7 @@ async def test_rollback_without_history_is_conflict(env, tmp_path): app = create_app(doc, http_transport=httpx.MockTransport(ok_upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: response = await client.post("/admin/rollback", headers=CSRF) assert response.status_code == 409 @@ -185,7 +185,7 @@ async def telemetry(request: httpx.Request) -> httpx.Response: ) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: await client.post("/v1/chat/completions", json={"model": "cerberus/free", "messages": []}) assert len(events) == 1 @@ -219,7 +219,7 @@ async def telemetry(request: httpx.Request) -> httpx.Response: ) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: armed = await client.post("/admin/shadow", json={"path": shadow}, headers=CSRF) response = await client.post("/v1/chat/completions", json={"model": "cerberus/free", "messages": []}) cleared = await client.post("/admin/shadow", json={"path": None}, headers=CSRF) @@ -241,7 +241,7 @@ async def test_admin_status_reports_active_and_shadow(env, tmp_path): app = create_app(load_config_document(active), http_transport=httpx.MockTransport(ok_upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: status = await client.get("/admin/status") config_dump = await client.get("/admin/config/active") @@ -279,7 +279,7 @@ async def telemetry(request: httpx.Request) -> httpx.Response: ) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: await client.post("/admin/shadow", json={"path": shadow}, headers=CSRF) await client.post("/v1/chat/completions", json={"model": "cerberus/free", "messages": []}) diff --git a/tests/test_decisions.py b/tests/test_decisions.py index e25885d..10bbb30 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -100,7 +100,7 @@ def build(tmp_path, raw, *, transport, telemetry_transport=None, name="config.ya def client_for(app): return httpx.AsyncClient( - transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://test" + transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://localhost" ) diff --git a/tests/test_dispatch_control_plane.py b/tests/test_dispatch_control_plane.py index d766d79..c3ae743 100644 --- a/tests/test_dispatch_control_plane.py +++ b/tests/test_dispatch_control_plane.py @@ -214,7 +214,7 @@ async def upstream(request: httpx.Request) -> httpx.Response: app.state.control_plane.set_provider_health("unconfigured-provider", "healthy") async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: headers = {"authorization": "Bearer identity-secret"} body = {"model": "cerberus/coding-fast", "messages": [{"role": "user", "content": "code"}]} ok = await client.post("/v1/chat/completions", json=body, headers=headers) @@ -374,7 +374,7 @@ def flaky(request: httpx.Request) -> httpx.Response: app = create_app(doc, http_transport=httpx.MockTransport(flaky)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: probe = await client.post( "/admin/providers/local/test", headers={"x-cerberus-csrf": "1"} ) diff --git a/tests/test_first_run.py b/tests/test_first_run.py index bca4a0f..5b09f8f 100644 --- a/tests/test_first_run.py +++ b/tests/test_first_run.py @@ -38,7 +38,7 @@ def persisted_app(tmp_path, version="cerberus-2026-07-16.1", name="active.yaml") def client_for(app): return httpx.AsyncClient( - transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://test" + transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://localhost" ) diff --git a/tests/test_fusion_dispatch.py b/tests/test_fusion_dispatch.py index 83167cc..3f7f2e2 100644 --- a/tests/test_fusion_dispatch.py +++ b/tests/test_fusion_dispatch.py @@ -109,7 +109,7 @@ def make_app(monkeypatch, tmp_path, *, raw=None, respond=openrouter_ok, api_key= def client_for(app): - return httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://t") + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://localhost") AUTH = {"authorization": "Bearer cb-dev"} diff --git a/tests/test_identity.py b/tests/test_identity.py index ac59c02..8ee749d 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -60,7 +60,7 @@ def ok_upstream(_request: httpx.Request) -> httpx.Response: async def post(app, body, headers=None) -> httpx.Response: async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: return await client.post("/v1/chat/completions", json=body, headers=headers or {}) @@ -171,7 +171,7 @@ async def test_models_endpoint_is_scoped_to_the_identity(monkeypatch): app = create_app(identity_config(monkeypatch), http_transport=httpx.MockTransport(ok_upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: recon = await client.get("/v1/models", headers={"authorization": "Bearer cb-recon-key"}) coder = await client.get("/v1/models", headers={"x-api-key": "cb-coder-key"}) anonymous = await client.get("/v1/models") diff --git a/tests/test_jev_dispatch.py b/tests/test_jev_dispatch.py index fcb054d..2b3a521 100644 --- a/tests/test_jev_dispatch.py +++ b/tests/test_jev_dispatch.py @@ -142,7 +142,7 @@ def make_app(monkeypatch, tmp_path, *, raw=None, decide=None, generate=generatio def client_for(app): - return httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://t") + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://localhost") async def call(app, body=REQ): diff --git a/tests/test_jev_router_dispatch.py b/tests/test_jev_router_dispatch.py index e0d59fb..947e901 100644 --- a/tests/test_jev_router_dispatch.py +++ b/tests/test_jev_router_dispatch.py @@ -125,7 +125,7 @@ def make_app(monkeypatch, tmp_path, *, raw=None, respond=None, api_key="or-key") def client_for(app): - return httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://t") + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)), base_url="http://localhost") AUTH = {"authorization": "Bearer cb-dev"} diff --git a/tests/test_jwt_identity.py b/tests/test_jwt_identity.py index 01336d4..ba7b695 100644 --- a/tests/test_jwt_identity.py +++ b/tests/test_jwt_identity.py @@ -95,7 +95,7 @@ def ok_upstream(_request: httpx.Request) -> httpx.Response: async def post_token(app, token: str) -> httpx.Response: async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: return await client.post( "/v1/chat/completions", json={"model": "cerberus/free", "messages": []}, @@ -166,7 +166,7 @@ async def test_key_rotation_overlap_via_kid(monkeypatch): ) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: for key in (old, new): response = await client.post( "/v1/chat/completions", @@ -187,7 +187,7 @@ async def test_authentik_outage_after_cache_still_validates(monkeypatch): ) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: first = await client.post( "/v1/chat/completions", json={"model": "cerberus/free", "messages": []}, diff --git a/tests/test_loopback_host.py b/tests/test_loopback_host.py new file mode 100644 index 0000000..cde7463 --- /dev/null +++ b/tests/test_loopback_host.py @@ -0,0 +1,60 @@ +"""Loopback trust requires a Host naming this machine (DNS-rebinding defence). + +A hostile page open in a local operator's browser can rebind its own hostname +to 127.0.0.1. Its requests then arrive from a loopback peer, are same-origin, +and can set X-Cerberus-CSRF — so the peer address alone must not admit them. +""" + +import httpx +import pytest + +from cerberus.app import create_app, is_local_host_header +from tests.test_app import make_config + +CSRF = {"x-cerberus-csrf": "1"} + + +@pytest.mark.parametrize( + "header", + ["localhost", "LOCALHOST:4111", "127.0.0.1", "127.0.0.1:4000", "127.8.9.10", "[::1]:4111", "::1", "app.localhost"], +) +def test_local_host_headers(header): + assert is_local_host_header(header) + + +@pytest.mark.parametrize("header", [None, "", "evil.example", "evil.example:4111", "127.0.0.1.evil.example", "203.0.113.7"]) +def test_foreign_host_headers(header): + assert not is_local_host_header(header) + + +def test_trusted_names_are_opt_in(): + assert is_local_host_header("gateway.lan:4111", frozenset({"gateway.lan"})) + + +async def _get(app, path, host, method="GET", **kwargs): + async with app.router.lifespan_context(app): + transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)) + async with httpx.AsyncClient(transport=transport, base_url=f"http://{host}") as client: + return await client.request(method, path, **kwargs) + + +@pytest.mark.asyncio +async def test_rebound_hostname_gets_no_admin_access(monkeypatch): + app = create_app(make_config(monkeypatch), http_transport=httpx.MockTransport(lambda r: httpx.Response(200))) + assert (await _get(app, "/admin/config/active", "evil.example:4000")).status_code == 403 + rollback = await _get(app, "/admin/rollback", "evil.example:4000", method="POST", headers=CSRF) + assert rollback.status_code == 403 + assert (await _get(app, "/admin/config/active", "localhost:4000")).status_code == 200 + + +@pytest.mark.asyncio +async def test_rebound_hostname_cannot_use_tokenless_inference(monkeypatch): + app = create_app( + make_config(monkeypatch), + http_transport=httpx.MockTransport(lambda r: httpx.Response(200, json={"choices": []})), + ) + body = {"model": "cerberus/main", "messages": [{"role": "user", "content": "hi"}]} + denied = await _get(app, "/v1/chat/completions", "evil.example:4000", method="POST", json=body) + assert denied.status_code == 401 + allowed = await _get(app, "/v1/chat/completions", "127.0.0.1:4000", method="POST", json=body) + assert allowed.status_code == 200, allowed.text diff --git a/tests/test_persistent_state.py b/tests/test_persistent_state.py index 16c1926..d91a2c0 100644 --- a/tests/test_persistent_state.py +++ b/tests/test_persistent_state.py @@ -105,7 +105,7 @@ async def upstream(request: httpx.Request) -> httpx.Response: app = create_app(escalation_config(monkeypatch, tmp_path), http_transport=httpx.MockTransport(upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: response = await client.post("/v1/chat/completions", json={"model": "cerberus/free", "messages": []}) health = await client.get("/admin/health") @@ -126,7 +126,7 @@ async def upstream(_request: httpx.Request) -> httpx.Response: app_one = create_app(config, http_transport=httpx.MockTransport(upstream)) async with app_one.router.lifespan_context(app_one): transport = httpx.ASGITransport(app=app_one) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: await client.post("/v1/chat/completions", json={"model": "cerberus/free", "messages": []}) upstream_calls = 0 @@ -139,7 +139,7 @@ async def upstream_after_restart(_request: httpx.Request) -> httpx.Response: app_two = create_app(config, http_transport=httpx.MockTransport(upstream_after_restart)) async with app_two.router.lifespan_context(app_two): transport = httpx.ASGITransport(app=app_two) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: response = await client.post("/v1/chat/completions", json={"model": "cerberus/free", "messages": []}) assert response.status_code == 503 # still cooled from before the restart diff --git a/tests/test_route_projection.py b/tests/test_route_projection.py index 1c596e4..4aad012 100644 --- a/tests/test_route_projection.py +++ b/tests/test_route_projection.py @@ -97,7 +97,7 @@ def build(tmp_path, raw, *, transport=None): def client_for(app): return httpx.AsyncClient( - transport=httpx.ASGITransport(app=app, client=LOOPBACK), base_url="http://test" + transport=httpx.ASGITransport(app=app, client=LOOPBACK), base_url="http://localhost" ) diff --git a/tests/test_streaming_invariants.py b/tests/test_streaming_invariants.py index 319c16b..50c5a14 100644 --- a/tests/test_streaming_invariants.py +++ b/tests/test_streaming_invariants.py @@ -95,7 +95,7 @@ async def upstream(request: httpx.Request) -> httpx.Response: app = create_app(config, http_transport=httpx.MockTransport(upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: response = await client.post( "/v1/chat/completions", json={"model": "cerberus/stream-alias", "messages": [], "stream": True}, @@ -125,7 +125,7 @@ async def upstream(request: httpx.Request) -> httpx.Response: app = create_app(config, http_transport=httpx.MockTransport(upstream)) async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: response = await client.post( "/v1/chat/completions", json={"model": "cerberus/stream-alias", "messages": [], "stream": True}, @@ -163,7 +163,7 @@ async def upstream(request: httpx.Request) -> httpx.Response: # With raise_app_exceptions=False, ASGITransport yields the body parts # emitted before the mid-stream failure, exactly reflecting what the HTTP client received. transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: response = await client.post( "/v1/chat/completions", json={"model": "cerberus/stream-alias", "messages": [], "stream": True}, @@ -193,7 +193,7 @@ async def upstream(_request: httpx.Request) -> httpx.Response: async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: with pytest.raises(RuntimeError, match="controlled mid-stream failure"): async with client.stream( "POST", @@ -234,7 +234,7 @@ async def sink(request: httpx.Request) -> httpx.Response: async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: with pytest.raises(RuntimeError, match="controlled mid-stream failure"): async with client.stream( "POST", @@ -298,7 +298,7 @@ async def sink(request: httpx.Request) -> httpx.Response: async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: async def consumer(): async with client.stream( diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index fba9e6d..2ab3bf4 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -58,7 +58,7 @@ def telemetry_config(monkeypatch: pytest.MonkeyPatch, tmp_path) -> CerberusConfi async def _request(app, body: dict) -> httpx.Response: async with app.router.lifespan_context(app): - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://localhost") as client: return await client.post("/v1/chat/completions", json=body) @@ -134,7 +134,7 @@ async def telemetry(request: httpx.Request) -> httpx.Response: app = create_app(config, httpx.MockTransport(upstream), httpx.MockTransport(telemetry)) unsafe_values = ("prompt-sensitive-value", "x" * 1_000) async with app.router.lifespan_context(app): - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://localhost") as client: for unsafe in unsafe_values: response = await client.post("/v1/chat/completions", json={"model": unsafe, "messages": []}) assert response.status_code == 404 @@ -227,7 +227,7 @@ async def telemetry(request: httpx.Request) -> httpx.Response: app = create_app(config, httpx.MockTransport(upstream), httpx.MockTransport(telemetry)) async with app.router.lifespan_context(app): - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://localhost") as client: with pytest.raises(RuntimeError, match="controlled stream interruption"): await client.post( "/v1/chat/completions", @@ -274,7 +274,7 @@ async def telemetry(_request: httpx.Request) -> httpx.Response: with caplog.at_level(logging.ERROR, logger="cerberus.telemetry.emitter"): async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: inference = await client.post( "/v1/chat/completions", json={"model": "cerberus/controlled", "messages": []}, @@ -520,7 +520,7 @@ async def upstream(_request: httpx.Request) -> httpx.Response: doc_holder["app"] = app async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 40001)) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: response = await client.post("/v1/chat/completions", json={"model": "cerberus/free", "messages": []}) health = await client.get("/admin/health") events = await client.get("/admin/events") @@ -609,7 +609,7 @@ async def telemetry(request: httpx.Request) -> httpx.Response: app = create_app(config, httpx.MockTransport(upstream), httpx.MockTransport(telemetry)) async with app.router.lifespan_context(app): - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://localhost") as client: with pytest.raises(RuntimeError, match="controlled stream interruption"): await client.post( "/v1/chat/completions", @@ -665,7 +665,7 @@ async def telemetry(request: httpx.Request) -> httpx.Response: app = create_app(config, httpx.MockTransport(upstream), httpx.MockTransport(telemetry)) body = {"model": "cerberus/controlled", "messages": []} async with app.router.lifespan_context(app): - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://localhost") as client: first = await client.post("/v1/chat/completions", json=body) second = await client.post("/v1/chat/completions", json=body)