diff --git a/gateway/config.py b/gateway/config.py index 695341bca..552cf2f57 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -738,6 +738,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # API Server api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes") api_server_key = os.getenv("API_SERVER_KEY", "") + api_server_cors_origins = os.getenv("API_SERVER_CORS_ORIGINS", "") api_server_port = os.getenv("API_SERVER_PORT") api_server_host = os.getenv("API_SERVER_HOST") if api_server_enabled or api_server_key: @@ -746,6 +747,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.API_SERVER].enabled = True if api_server_key: config.platforms[Platform.API_SERVER].extra["key"] = api_server_key + if api_server_cors_origins: + origins = [origin.strip() for origin in api_server_cors_origins.split(",") if origin.strip()] + if origins: + config.platforms[Platform.API_SERVER].extra["cors_origins"] = origins if api_server_port: try: config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port) @@ -786,4 +791,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None: pass - diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 6da3e8104..d0fd301a1 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -96,7 +96,6 @@ class ResponseStore: # --------------------------------------------------------------------------- _CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type", } @@ -105,11 +104,23 @@ _CORS_HEADERS = { if AIOHTTP_AVAILABLE: @web.middleware async def cors_middleware(request, handler): - """Add CORS headers to every response; handle OPTIONS preflight.""" + """Add CORS headers for explicitly allowed origins; handle OPTIONS preflight.""" + adapter = request.app.get("api_server_adapter") + origin = request.headers.get("Origin", "") + cors_headers = None + if adapter is not None: + if not adapter._origin_allowed(origin): + return web.Response(status=403) + cors_headers = adapter._cors_headers_for_origin(origin) + if request.method == "OPTIONS": - return web.Response(status=200, headers=_CORS_HEADERS) + if cors_headers is None: + return web.Response(status=403) + return web.Response(status=200, headers=cors_headers) + response = await handler(request) - response.headers.update(_CORS_HEADERS) + if cors_headers is not None: + response.headers.update(cors_headers) return response else: cors_middleware = None # type: ignore[assignment] @@ -129,6 +140,9 @@ class APIServerAdapter(BasePlatformAdapter): self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST)) self._port: int = int(extra.get("port", os.getenv("API_SERVER_PORT", str(DEFAULT_PORT)))) self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", "")) + self._cors_origins: tuple[str, ...] = self._parse_cors_origins( + extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")), + ) self._app: Optional["web.Application"] = None self._runner: Optional["web.AppRunner"] = None self._site: Optional["web.TCPSite"] = None @@ -136,6 +150,49 @@ class APIServerAdapter(BasePlatformAdapter): # Conversation name → latest response_id mapping self._conversations: Dict[str, str] = {} + @staticmethod + def _parse_cors_origins(value: Any) -> tuple[str, ...]: + """Normalize configured CORS origins into a stable tuple.""" + if not value: + return () + + if isinstance(value, str): + items = value.split(",") + elif isinstance(value, (list, tuple, set)): + items = value + else: + items = [str(value)] + + return tuple(str(item).strip() for item in items if str(item).strip()) + + def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]: + """Return CORS headers for an allowed browser origin.""" + if not origin or not self._cors_origins: + return None + + if "*" in self._cors_origins: + headers = dict(_CORS_HEADERS) + headers["Access-Control-Allow-Origin"] = "*" + return headers + + if origin not in self._cors_origins: + return None + + headers = dict(_CORS_HEADERS) + headers["Access-Control-Allow-Origin"] = origin + headers["Vary"] = "Origin" + return headers + + def _origin_allowed(self, origin: str) -> bool: + """Allow non-browser clients and explicitly configured browser origins.""" + if not origin: + return True + + if not self._cors_origins: + return False + + return "*" in self._cors_origins or origin in self._cors_origins + # ------------------------------------------------------------------ # Auth helper # ------------------------------------------------------------------ @@ -903,6 +960,7 @@ class APIServerAdapter(BasePlatformAdapter): try: self._app = web.Application(middlewares=[cors_middleware]) + self._app["api_server_adapter"] = self self._app.router.add_get("/health", self._handle_health) self._app.router.add_get("/v1/models", self._handle_models) self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 2a30e3c75..89d713ef0 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -119,22 +119,33 @@ class TestAdapterInit: def test_custom_config_from_extra(self): config = PlatformConfig( enabled=True, - extra={"host": "0.0.0.0", "port": 9999, "key": "sk-test"}, + extra={ + "host": "0.0.0.0", + "port": 9999, + "key": "sk-test", + "cors_origins": ["http://localhost:3000"], + }, ) adapter = APIServerAdapter(config) assert adapter._host == "0.0.0.0" assert adapter._port == 9999 assert adapter._api_key == "sk-test" + assert adapter._cors_origins == ("http://localhost:3000",) def test_config_from_env(self, monkeypatch): monkeypatch.setenv("API_SERVER_HOST", "10.0.0.1") monkeypatch.setenv("API_SERVER_PORT", "7777") monkeypatch.setenv("API_SERVER_KEY", "sk-env") + monkeypatch.setenv("API_SERVER_CORS_ORIGINS", "http://localhost:3000, http://127.0.0.1:3000") config = PlatformConfig(enabled=True) adapter = APIServerAdapter(config) assert adapter._host == "10.0.0.1" assert adapter._port == 7777 assert adapter._api_key == "sk-env" + assert adapter._cors_origins == ( + "http://localhost:3000", + "http://127.0.0.1:3000", + ) # --------------------------------------------------------------------------- @@ -190,11 +201,13 @@ class TestAuth: # --------------------------------------------------------------------------- -def _make_adapter(api_key: str = "") -> APIServerAdapter: +def _make_adapter(api_key: str = "", cors_origins=None) -> APIServerAdapter: """Create an adapter with optional API key.""" extra = {} if api_key: extra["key"] = api_key + if cors_origins is not None: + extra["cors_origins"] = cors_origins config = PlatformConfig(enabled=True, extra=extra) return APIServerAdapter(config) @@ -202,6 +215,7 @@ def _make_adapter(api_key: str = "") -> APIServerAdapter: def _create_app(adapter: APIServerAdapter) -> web.Application: """Create the aiohttp app from the adapter (without starting the full server).""" app = web.Application(middlewares=[cors_middleware]) + app["api_server_adapter"] = adapter app.router.add_get("/health", adapter._handle_health) app.router.add_get("/v1/models", adapter._handle_models) app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions) @@ -788,6 +802,19 @@ class TestConfigIntegration: assert config.platforms[Platform.API_SERVER].extra.get("port") == 9999 assert config.platforms[Platform.API_SERVER].extra.get("host") == "0.0.0.0" + def test_env_override_cors_origins(self, monkeypatch): + monkeypatch.setenv("API_SERVER_ENABLED", "true") + monkeypatch.setenv( + "API_SERVER_CORS_ORIGINS", + "http://localhost:3000, http://127.0.0.1:3000", + ) + from gateway.config import load_gateway_config + config = load_gateway_config() + assert config.platforms[Platform.API_SERVER].extra.get("cors_origins") == [ + "http://localhost:3000", + "http://127.0.0.1:3000", + ] + def test_api_server_in_connected_platforms(self): config = GatewayConfig() config.platforms[Platform.API_SERVER] = PlatformConfig(enabled=True) @@ -1156,26 +1183,91 @@ class TestTruncation: class TestCORS: + def test_origin_allowed_for_non_browser_client(self, adapter): + assert adapter._origin_allowed("") is True + + def test_origin_rejected_by_default(self, adapter): + assert adapter._origin_allowed("http://evil.example") is False + + def test_origin_allowed_for_allowlist_match(self): + adapter = _make_adapter(cors_origins=["http://localhost:3000"]) + assert adapter._origin_allowed("http://localhost:3000") is True + + def test_cors_headers_for_origin_disabled_by_default(self, adapter): + assert adapter._cors_headers_for_origin("http://localhost:3000") is None + + def test_cors_headers_for_origin_matches_allowlist(self): + adapter = _make_adapter(cors_origins=["http://localhost:3000"]) + headers = adapter._cors_headers_for_origin("http://localhost:3000") + assert headers is not None + assert headers["Access-Control-Allow-Origin"] == "http://localhost:3000" + assert "POST" in headers["Access-Control-Allow-Methods"] + + def test_cors_headers_for_origin_rejects_unknown_origin(self): + adapter = _make_adapter(cors_origins=["http://localhost:3000"]) + assert adapter._cors_headers_for_origin("http://evil.example") is None + @pytest.mark.asyncio - async def test_cors_headers_on_get(self, adapter): - """CORS headers present on normal responses.""" + async def test_cors_headers_not_present_by_default(self, adapter): + """CORS is disabled unless explicitly configured.""" app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: resp = await cli.get("/health") assert resp.status == 200 - assert resp.headers.get("Access-Control-Allow-Origin") == "*" + assert resp.headers.get("Access-Control-Allow-Origin") is None + + @pytest.mark.asyncio + async def test_browser_origin_rejected_by_default(self, adapter): + """Browser-originated requests are rejected unless explicitly allowed.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/health", headers={"Origin": "http://evil.example"}) + assert resp.status == 403 + assert resp.headers.get("Access-Control-Allow-Origin") is None + + @pytest.mark.asyncio + async def test_cors_options_preflight_rejected_by_default(self, adapter): + """Browser preflight is rejected unless CORS is explicitly configured.""" + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.options( + "/v1/chat/completions", + headers={ + "Origin": "http://evil.example", + "Access-Control-Request-Method": "POST", + }, + ) + assert resp.status == 403 + assert resp.headers.get("Access-Control-Allow-Origin") is None + + @pytest.mark.asyncio + async def test_cors_headers_present_for_allowed_origin(self): + """Allowed origins receive explicit CORS headers.""" + adapter = _make_adapter(cors_origins=["http://localhost:3000"]) + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/health", headers={"Origin": "http://localhost:3000"}) + assert resp.status == 200 + assert resp.headers.get("Access-Control-Allow-Origin") == "http://localhost:3000" assert "POST" in resp.headers.get("Access-Control-Allow-Methods", "") assert "DELETE" in resp.headers.get("Access-Control-Allow-Methods", "") @pytest.mark.asyncio - async def test_cors_options_preflight(self, adapter): - """OPTIONS preflight request returns CORS headers.""" + async def test_cors_options_preflight_allowed_for_configured_origin(self): + """Configured origins can complete browser preflight.""" + adapter = _make_adapter(cors_origins=["http://localhost:3000"]) app = _create_app(adapter) async with TestClient(TestServer(app)) as cli: - # OPTIONS to a known path — aiohttp will route through middleware - resp = await cli.options("/health") + resp = await cli.options( + "/v1/chat/completions", + headers={ + "Origin": "http://localhost:3000", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Authorization, Content-Type", + }, + ) assert resp.status == 200 - assert resp.headers.get("Access-Control-Allow-Origin") == "*" + assert resp.headers.get("Access-Control-Allow-Origin") == "http://localhost:3000" assert "Authorization" in resp.headers.get("Access-Control-Allow-Headers", "") diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 31ed5ec49..fe3b927c7 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -212,9 +212,10 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `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. If empty, all requests are allowed (local-only use). | +| `API_SERVER_KEY` | Bearer token for API server authentication. Strongly recommended; required for any network-accessible deployment. | +| `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 — set `API_SERVER_KEY` for security. | +| `API_SERVER_HOST` | Host/bind address for the API server (default: `127.0.0.1`). Use `0.0.0.0` for network access only with `API_SERVER_KEY` and a narrow `API_SERVER_CORS_ORIGINS` allowlist. | | `MESSAGING_CWD` | Working directory for terminal commands in messaging mode (default: `~`) | | `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms | | `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) | diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index 376676ef4..995f1abd9 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -18,6 +18,9 @@ Add to `~/.hermes/.env`: ```bash API_SERVER_ENABLED=true +API_SERVER_KEY=change-me-local-dev +# Optional: only if a browser must call Hermes directly +# API_SERVER_CORS_ORIGINS=http://localhost:3000 ``` ### 2. Start the gateway @@ -39,6 +42,7 @@ Point any OpenAI-compatible client at `http://localhost:8642/v1`: ```bash # Test with curl curl http://localhost:8642/v1/chat/completions \ + -H "Authorization: Bearer change-me-local-dev" \ -H "Content-Type: application/json" \ -d '{"model": "hermes-agent", "messages": [{"role": "user", "content": "Hello!"}]}' ``` @@ -168,12 +172,12 @@ Bearer token auth via the `Authorization` header: Authorization: Bearer *** ``` -Configure the key via `API_SERVER_KEY` env var. If no key is set, all requests are allowed (for local-only use). +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**. If you change the bind address to `0.0.0.0` (network-accessible), **always set `API_SERVER_KEY`** — without it, anyone on your network can execute arbitrary commands on your machine. +The API server gives full access to hermes-agent's toolset, **including terminal commands**. If you change the bind address to `0.0.0.0` (network-accessible), **always set `API_SERVER_KEY`** and keep `API_SERVER_CORS_ORIGINS` narrow — without that, remote callers may be able to execute arbitrary commands on your machine. -The default bind address (`127.0.0.1`) is safe for local-only use. +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. ::: ## Configuration @@ -186,6 +190,7 @@ The default bind address (`127.0.0.1`) is safe for local-only use. | `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_CORS_ORIGINS` | _(none)_ | Comma-separated allowed browser origins | ### config.yaml @@ -196,7 +201,15 @@ The default bind address (`127.0.0.1`) is safe for local-only use. ## CORS -The API server includes CORS headers on all responses (`Access-Control-Allow-Origin: *`), so browser-based frontends can connect directly. +The API server does **not** enable browser CORS by default. + +For direct browser access, set an explicit allowlist: + +```bash +API_SERVER_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +``` + +Most documented frontends such as Open WebUI connect server-to-server and do not need CORS at all. ## Compatible Frontends diff --git a/website/docs/user-guide/messaging/open-webui.md b/website/docs/user-guide/messaging/open-webui.md index 23c8fbdce..a3eb5fbc0 100644 --- a/website/docs/user-guide/messaging/open-webui.md +++ b/website/docs/user-guide/messaging/open-webui.md @@ -20,6 +20,8 @@ flowchart LR Open WebUI connects to Hermes Agent's API server just like it would connect to OpenAI. Your agent handles the requests with its full toolset — terminal, file operations, web search, memory, skills — and returns the final response. +Open WebUI talks to Hermes server-to-server, so you do not need `API_SERVER_CORS_ORIGINS` for this integration. + ## Quick Setup ### 1. Enable the API server @@ -28,8 +30,7 @@ Add to `~/.hermes/.env`: ```bash API_SERVER_ENABLED=true -# Optional: set a key for auth (recommended if accessible beyond localhost) -# API_SERVER_KEY=your-secret-key +API_SERVER_KEY=your-secret-key ``` ### 2. Start Hermes Agent gateway @@ -49,7 +50,7 @@ You should see: ```bash docker run -d -p 3000:8080 \ -e OPENAI_API_BASE_URL=http://host.docker.internal:8642/v1 \ - -e OPENAI_API_KEY=not-needed \ + -e OPENAI_API_KEY=your-secret-key \ --add-host=host.docker.internal:host-gateway \ -v open-webui:/app/backend/data \ --name open-webui \ @@ -57,12 +58,6 @@ docker run -d -p 3000:8080 \ ghcr.io/open-webui/open-webui:main ``` -If you set an `API_SERVER_KEY`, use it instead of `not-needed`: - -```bash --e OPENAI_API_KEY=your-secret-key -``` - ### 4. Open the UI Go to **http://localhost:3000**. Create your admin account (the first user becomes admin). You should see **hermes-agent** in the model dropdown. Start chatting! @@ -81,7 +76,7 @@ services: - open-webui:/app/backend/data environment: - OPENAI_API_BASE_URL=http://host.docker.internal:8642/v1 - - OPENAI_API_KEY=not-needed + - OPENAI_API_KEY=your-secret-key extra_hosts: - "host.docker.internal:host-gateway" restart: always @@ -167,7 +162,7 @@ Your agent has access to all the same tools and capabilities as when using the C | `API_SERVER_ENABLED` | `false` | Enable the API server | | `API_SERVER_PORT` | `8642` | HTTP server port | | `API_SERVER_HOST` | `127.0.0.1` | Bind address | -| `API_SERVER_KEY` | _(none)_ | Bearer token for auth. No key = allow all. | +| `API_SERVER_KEY` | _(required)_ | Bearer token for auth. Match `OPENAI_API_KEY`. | ### Open WebUI @@ -195,7 +190,7 @@ Hermes Agent may be executing multiple tool calls (reading files, running comman ### "Invalid API key" errors -Make sure your `OPENAI_API_KEY` in Open WebUI matches the `API_SERVER_KEY` in Hermes Agent. If no key is configured on the Hermes side, any non-empty value works. +Make sure your `OPENAI_API_KEY` in Open WebUI matches the `API_SERVER_KEY` in Hermes Agent. ## Linux Docker (no Docker Desktop)