From 2d8d08cae691b6774df1f50ee3cb4667ac6a5283 Mon Sep 17 00:00:00 2001 From: SahilRakhaiya05 Date: Wed, 1 Jul 2026 02:58:14 -0700 Subject: [PATCH] fix(api-server): require auth for /health/detailed and fail closed on weak keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /health/detailed leaked runtime state (gateway state, connected platforms, active-agent counts, PID, exit reason) with no auth. Gate it behind the same Bearer auth as other API routes; plain /health stays open for liveness probes. Also refuse to start on a placeholder/too-short (<16 char) API_SERVER_KEY regardless of bind address — a guessable key on a terminal-capable endpoint is RCE-adjacent even on loopback, since any local process can reach it. The required-key check was already unconditional; this extends the strength floor to loopback binds too. Startup guards are hoisted above app/background-task creation so a rejected start leaves no partial state. Salvaged from #44073 (external-surface hardening), split into a focused PR per maintainer request. Co-authored-by: Hermes Agent --- gateway/platforms/api_server.py | 97 +++++++++++---------- tests/gateway/test_api_server.py | 13 ++- tests/gateway/test_api_server_bind_guard.py | 13 +++ 3 files changed, 77 insertions(+), 46 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index c5d28b0aa4d..1e4ad4c3a9a 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1165,8 +1165,12 @@ class APIServerAdapter(BasePlatformAdapter): Returns gateway state, connected platforms, PID, and uptime so the dashboard can display full status without needing a shared PID file or - /proc access. No authentication required. + /proc access. Requires the same Bearer auth as other API routes. """ + auth_err = self._check_auth(request) + if auth_err: + return auth_err + from gateway.status import ( derive_gateway_busy, derive_gateway_drainable, @@ -4454,12 +4458,60 @@ class APIServerAdapter(BasePlatformAdapter): # BasePlatformAdapter interface # ------------------------------------------------------------------ + def _api_key_passes_startup_guard(self) -> bool: + """Return True when API_SERVER_KEY is present and strong enough to start.""" + if not self._api_key: + logger.error( + "[%s] Refusing to start: API_SERVER_KEY is required for the API server, " + "including loopback-only binds on %s.", + self.name, self._host, + ) + return False + + try: + from hermes_cli.auth import has_usable_secret + if not has_usable_secret(self._api_key, min_length=16): + logger.error( + "[%s] Refusing to start: API_SERVER_KEY is a " + "placeholder or too short (<16 chars). This endpoint " + "dispatches terminal-capable agent work — a guessable " + "key is remote code execution. Generate a strong secret " + "(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY " + "before starting the API server on %s.", + self.name, self._host, + ) + return False + except ImportError: + pass + return True + + def _port_is_available(self) -> bool: + """Return True when the configured listen port is free.""" + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s: + _s.settimeout(1) + _s.connect(('127.0.0.1', self._port)) + logger.error( + "[%s] Port %d already in use. Set a different port in config.yaml: " + "platforms.api_server.port", + self.name, self._port, + ) + return False + except (ConnectionRefusedError, OSError): + return True + async def connect(self, *, is_reconnect: bool = False) -> bool: """Start the aiohttp web server.""" if not AIOHTTP_AVAILABLE: logger.warning("[%s] aiohttp not installed", self.name) return False + if not self._api_key_passes_startup_guard(): + return False + + if not self._port_is_available(): + return False + try: mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None] self._app = web.Application(middlewares=mws, client_max_size=MAX_REQUEST_BYTES) @@ -4520,39 +4572,6 @@ class APIServerAdapter(BasePlatformAdapter): if hasattr(sweep_task, "add_done_callback"): sweep_task.add_done_callback(self._background_tasks.discard) - # Refuse to start without authentication. The API server can - # dispatch terminal-capable agent work, so every deployment needs - # an explicit API_SERVER_KEY regardless of bind address. - if not self._api_key: - logger.error( - "[%s] Refusing to start: API_SERVER_KEY is required for the API server, " - "including loopback-only binds on %s.", - self.name, self._host, - ) - return False - - # Refuse to start network-accessible with a placeholder or weak key. - # Ported from openclaw/openclaw#64586; entropy floor raised to 16 in - # the June 2026 hermes-0day hardening (an 8-char key dispatching - # terminal-capable agent work on a public bind is brute-forceable). - if is_network_accessible(self._host) and self._api_key: - try: - from hermes_cli.auth import has_usable_secret - if not has_usable_secret(self._api_key, min_length=16): - logger.error( - "[%s] Refusing to start: API_SERVER_KEY is a " - "placeholder or too short (<16 chars) for a " - "network-accessible bind. This endpoint dispatches " - "terminal-capable agent work — a guessable key is " - "remote code execution. Generate a strong secret " - "(e.g. `openssl rand -hex 32`) and set " - "API_SERVER_KEY before exposing it on %s.", - self.name, self._host, - ) - return False - except ImportError: - pass - # Loud warning when a network-accessible API server runs against an # unsandboxed local terminal backend. The API server can drive the # agent's terminal/file tools as the host user; on a public bind @@ -4581,16 +4600,6 @@ class APIServerAdapter(BasePlatformAdapter): self.name, self._host, ) - # Port conflict detection — fail fast if port is already in use - try: - with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s: - _s.settimeout(1) - _s.connect(('127.0.0.1', self._port)) - logger.error('[%s] Port %d already in use. Set a different port in config.yaml: platforms.api_server.port', self.name, self._port) - return False - except (ConnectionRefusedError, OSError): - pass # port is free - self._runner = web.AppRunner(self._app) await self._runner.setup() self._site = web.TCPSite(self._runner, self._host, self._port) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 25cbd3ec936..a94b34f4dc9 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -772,12 +772,21 @@ class TestHealthDetailedEndpoint: assert data["gateway_drainable"] is False @pytest.mark.asyncio - async def test_health_detailed_does_not_require_auth(self, auth_adapter): - """Health detailed endpoint should be accessible without auth, like /health.""" + async def test_health_detailed_requires_auth(self, auth_adapter): + """Detailed health must not leak runtime state without Bearer auth.""" app = _create_app(auth_adapter) with patch("gateway.status.read_runtime_status", return_value=None): async with TestClient(TestServer(app)) as cli: resp = await cli.get("/health/detailed") + assert resp.status == 401 + + @pytest.mark.asyncio + async def test_health_detailed_allows_authenticated_request(self, auth_adapter): + app = _create_app(auth_adapter) + headers = {"Authorization": f"Bearer {auth_adapter._api_key}"} + with patch("gateway.status.read_runtime_status", return_value={"gateway_state": "running"}): + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/health/detailed", headers=headers) assert resp.status == 200 diff --git a/tests/gateway/test_api_server_bind_guard.py b/tests/gateway/test_api_server_bind_guard.py index edab34eb382..706059887d9 100644 --- a/tests/gateway/test_api_server_bind_guard.py +++ b/tests/gateway/test_api_server_bind_guard.py @@ -119,6 +119,19 @@ class TestConnectBindGuard: assert is_network_accessible(adapter._host) is False result = await adapter.connect() assert result is False + assert adapter._app is None + assert adapter._background_tasks == set() + + @pytest.mark.asyncio + async def test_refuses_weak_key_without_partial_startup(self): + """Weak API_SERVER_KEY rejection must not create app or background tasks.""" + adapter = APIServerAdapter( + PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "key": "short"}), + ) + result = await adapter.connect() + assert result is False + assert adapter._app is None + assert adapter._background_tasks == set() @pytest.mark.asyncio async def test_allows_wildcard_with_key(self):