diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index adc42d5dee..527aae07cf 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1645,17 +1645,16 @@ async def get_status(): # Module not importable yet (early startup) — leave as []. pass - return { + # Always-public liveness + auth-gate shape. Safe for external uptime + # probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login + # bootstrap, and anyone who can curl the host — i.e. exactly the audience + # ``PUBLIC_API_PATHS`` documents this endpoint as serving. + status = { "version": __version__, "release_date": __release_date__, - "hermes_home": str(get_hermes_home()), - "config_path": str(get_config_path()), - "env_path": str(get_env_path()), "config_version": current_ver, "latest_config_version": latest_ver, "gateway_running": gateway_running, - "gateway_pid": gateway_pid, - "gateway_health_url": _GATEWAY_HEALTH_URL, "gateway_state": gateway_state, "gateway_platforms": gateway_platforms, "gateway_exit_reason": gateway_exit_reason, @@ -1665,6 +1664,27 @@ async def get_status(): "auth_providers": auth_providers, } + # Absolute host paths, the gateway PID, and the internal gateway health + # URL are deployment recon a liveness probe never needs. ``/api/status`` + # is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a + # network-exposed (gated) bind that means *any* unauthenticated caller + # reaches it, and leaking host metadata there contradicts the allowlist's + # own contract ("version, gateway state, active session count, and the + # dashboard auth-gate shape. No bodies, no session content, no secrets"). + # Surface this detail only on a loopback / ``--insecure`` bind, where the + # dashboard is local-only and the caller is already inside the trust + # envelope — the same loopback/gated split ``should_require_auth`` draws. + if not auth_required: + status.update({ + "hermes_home": str(get_hermes_home()), + "config_path": str(get_config_path()), + "env_path": str(get_env_path()), + "gateway_pid": gateway_pid, + "gateway_health_url": _GATEWAY_HEALTH_URL, + }) + + return status + _WINDOWS_11_MIN_BUILD = 22000 diff --git a/tests/hermes_cli/test_dashboard_auth_status_endpoint.py b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py index 9e1de3e76e..277cd03adc 100644 --- a/tests/hermes_cli/test_dashboard_auth_status_endpoint.py +++ b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py @@ -96,3 +96,40 @@ def test_status_preserves_existing_fields(loopback_client): } missing = expected_keys - set(body.keys()) assert not missing, f"/api/status dropped fields: {missing}" + + +# Host-local detail (absolute paths, PID, internal gateway URL) is deployment +# recon a liveness probe never needs. ``/api/status`` bypasses dashboard auth +# (it is in ``PUBLIC_API_PATHS``), so on a network-exposed bind it must not +# leak that detail to anonymous callers. +_HOST_DETAIL_FIELDS = frozenset({ + "hermes_home", "config_path", "env_path", "gateway_pid", + "gateway_health_url", +}) + + +def test_status_withholds_host_detail_in_gated_mode(gated_client): + """On a gated (non-loopback) bind, the public ``/api/status`` probe must + expose only the liveness + auth-gate shape — never absolute host paths, + the gateway PID, or the internal gateway health URL. The endpoint + bypasses dashboard auth, so anyone who can reach the host hits it cold.""" + r = gated_client.get("/api/status") + assert r.status_code == 200 + body = r.json() + # Liveness / auth-gate shape stays public. + for key in ("version", "gateway_state", "auth_required", "auth_providers"): + assert key in body, f"liveness field {key!r} must stay public" + # Deployment recon must be withheld from the anonymous public probe. + leaked = _HOST_DETAIL_FIELDS & set(body.keys()) + assert not leaked, f"/api/status leaked host detail under the gate: {leaked}" + + +def test_status_includes_host_detail_in_loopback_mode(loopback_client): + """Counterpart to the gated case: a loopback bind is local-only, so the + full payload (including host paths and PID) is still served — preserving + the StatusPage / ``hermes status`` experience for local operators.""" + r = loopback_client.get("/api/status") + assert r.status_code == 200 + body = r.json() + missing = _HOST_DETAIL_FIELDS - set(body.keys()) + assert not missing, f"loopback /api/status should keep host detail: {missing}"