From 1a9ef83147547dcd3ac1f59df32ed65d7e661ec4 Mon Sep 17 00:00:00 2001 From: Dusk1e Date: Mon, 25 May 2026 18:19:00 +0300 Subject: [PATCH] fix(security): require API_SERVER_KEY before dispatching API server work --- gateway/platforms/api_server.py | 27 ++++++++----------- hermes_cli/config.py | 6 ++--- tests/gateway/test_api_server_bind_guard.py | 11 ++++---- .../docs/reference/environment-variables.md | 4 +-- .../docs/user-guide/features/api-server.md | 6 ++--- 5 files changed, 24 insertions(+), 30 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 7d8afa64625..a56be55736a 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -24,7 +24,8 @@ Exposes an HTTP server with endpoints: Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat, AnythingLLM, NextChat, ChatBox, etc.) can connect to hermes-agent -through this adapter by pointing at http://localhost:8642/v1. +through this adapter by pointing at http://localhost:8642/v1 and +authenticating with API_SERVER_KEY. Requires: - aiohttp (already available in the gateway) @@ -844,11 +845,11 @@ class APIServerAdapter(BasePlatformAdapter): Validate Bearer token from Authorization header. Returns None if auth is OK, or a 401 web.Response on failure. - If no API key is configured, all requests are allowed (only when API - server is local). + connect() refuses to start the API server without API_SERVER_KEY, so + the no-key branch only exists for tests or unsupported manual wiring. """ if not self._api_key: - return None # No key configured — allow all (local-only use) + return None auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): @@ -4099,11 +4100,13 @@ class APIServerAdapter(BasePlatformAdapter): if hasattr(sweep_task, "add_done_callback"): sweep_task.add_done_callback(self._background_tasks.discard) - # Refuse to start network-accessible without authentication - if is_network_accessible(self._host) and not self._api_key: + # 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: binding to %s requires API_SERVER_KEY. " - "Set API_SERVER_KEY or use the default 127.0.0.1.", + "[%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 @@ -4141,14 +4144,6 @@ class APIServerAdapter(BasePlatformAdapter): await self._site.start() self._mark_connected() - if not self._api_key: - logger.warning( - "[%s] ⚠️ No API key configured (API_SERVER_KEY / platforms.api_server.key). " - "All requests will be accepted without authentication. " - "Set an API key for production deployments to prevent " - "unauthorized access to sessions, responses, and cron jobs.", - self.name, - ) logger.info( "[%s] API server listening on http://%s:%d (model: %s)", self.name, self._host, self._port, self._model_name, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7dc7ae50425..297f18b041e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2992,8 +2992,8 @@ OPTIONAL_ENV_VARS = { "advanced": True, }, "API_SERVER_KEY": { - "description": "Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.", - "prompt": "API server auth key (required for network access)", + "description": "Bearer token for API server authentication. Required whenever the API server is enabled; server refuses to start without it.", + "prompt": "API server auth key", "url": None, "password": True, "category": "messaging", @@ -3008,7 +3008,7 @@ OPTIONAL_ENV_VARS = { "advanced": True, }, "API_SERVER_HOST": { - "description": "Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.", + "description": "Host/bind address for the API server (default: 127.0.0.1). API_SERVER_KEY is still required even on loopback binds.", "prompt": "API server host", "url": None, "password": False, diff --git a/tests/gateway/test_api_server_bind_guard.py b/tests/gateway/test_api_server_bind_guard.py index 13a09c9ec49..fa43f8c4630 100644 --- a/tests/gateway/test_api_server_bind_guard.py +++ b/tests/gateway/test_api_server_bind_guard.py @@ -1,7 +1,7 @@ """Tests for the API server bind-address startup guard. Validates that is_network_accessible() correctly classifies addresses and -that connect() refuses to start on non-loopback without API_SERVER_KEY. +that connect() refuses to start without API_SERVER_KEY. """ import socket @@ -111,13 +111,14 @@ class TestConnectBindGuard: result = await adapter.connect() assert result is False - def test_allows_loopback_without_key(self): - """Loopback with no key should pass the guard.""" + @pytest.mark.asyncio + async def test_refuses_loopback_without_key(self): + """Loopback binds are still an auth boundary and require API_SERVER_KEY.""" adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"host": "127.0.0.1"})) assert adapter._api_key == "" - # The guard condition: is_network_accessible(host) AND NOT api_key - # For loopback, is_network_accessible is False so the guard does not block. assert is_network_accessible(adapter._host) is False + result = await adapter.connect() + assert result is False @pytest.mark.asyncio async def test_allows_wildcard_with_key(self): diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 93b617b0666..c6c56470a08 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -405,10 +405,10 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `WEBHOOK_PORT` | HTTP server port for receiving webhooks (default: `8644`) | | `WEBHOOK_SECRET` | Global HMAC secret for webhook signature validation (used as fallback when routes don't specify their own) | | `API_SERVER_ENABLED` | Enable the OpenAI-compatible API server (`true`/`false`). Runs alongside other platforms. | -| `API_SERVER_KEY` | Bearer token for API server authentication. Enforced for non-loopback binding. | +| `API_SERVER_KEY` | Bearer token for API server authentication. Required whenever the API server is enabled. | | `API_SERVER_CORS_ORIGINS` | Comma-separated browser origins allowed to call the API server directly (for example `http://localhost:3000,http://127.0.0.1:3000`). Default: disabled. | | `API_SERVER_PORT` | Port for the API server (default: `8642`) | -| `API_SERVER_HOST` | Host/bind address for the API server (default: `127.0.0.1`). Use `0.0.0.0` for network access — requires `API_SERVER_KEY` and a narrow `API_SERVER_CORS_ORIGINS` allowlist. | +| `API_SERVER_HOST` | Host/bind address for the API server (default: `127.0.0.1`). `API_SERVER_KEY` is still required on loopback; use a narrow `API_SERVER_CORS_ORIGINS` allowlist for browser access. | | `API_SERVER_MODEL_NAME` | Model name advertised on `/v1/models`. Defaults to the profile name (or `hermes-agent` for the default profile). Useful for multi-user setups where frontends like Open WebUI need distinct model names per connection. | | `GATEWAY_PROXY_URL` | URL of a remote Hermes API server to forward messages to ([proxy mode](/user-guide/messaging/matrix#proxy-mode-e2ee-on-macos)). When set, the gateway handles platform I/O only — all agent work is delegated to the remote server. Also configurable via `gateway.proxy_url` in `config.yaml`. | | `GATEWAY_PROXY_KEY` | Bearer token for authenticating with the remote API server in proxy mode. Must match `API_SERVER_KEY` on the remote host. | diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index fd883e84a96..7cc28f56a4a 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -327,9 +327,7 @@ Authorization: Bearer *** Configure the key via `API_SERVER_KEY` env var. If you need a browser to call Hermes directly, also set `API_SERVER_CORS_ORIGINS` to an explicit allowlist. :::warning Security -The API server gives full access to hermes-agent's toolset, **including terminal commands**. When binding to a non-loopback address like `0.0.0.0`, `API_SERVER_KEY` is **required**. Also keep `API_SERVER_CORS_ORIGINS` narrow to control browser access. - -The default bind address (`127.0.0.1`) is for local-only use. Browser access is disabled by default; enable it only for explicit trusted origins. +The API server gives full access to hermes-agent's toolset, **including terminal commands**. `API_SERVER_KEY` is **required for every deployment**, including the default loopback bind on `127.0.0.1`. Keep `API_SERVER_CORS_ORIGINS` narrow to control browser access when you explicitly allow browser callers. ::: ## Configuration @@ -341,7 +339,7 @@ The default bind address (`127.0.0.1`) is for local-only use. Browser access is | `API_SERVER_ENABLED` | `false` | Enable the API server | | `API_SERVER_PORT` | `8642` | HTTP server port | | `API_SERVER_HOST` | `127.0.0.1` | Bind address (localhost only by default) | -| `API_SERVER_KEY` | _(none)_ | Bearer token for auth | +| `API_SERVER_KEY` | _(required)_ | Bearer token for auth | | `API_SERVER_CORS_ORIGINS` | _(none)_ | Comma-separated allowed browser origins | | `API_SERVER_MODEL_NAME` | _(profile name)_ | Model name on `/v1/models`. Defaults to profile name, or `hermes-agent` for default profile. |