fix(security): block untrusted browser access to api server (#2451)
Co-authored-by: ifrederico <fr@tecompanytea.com>
This commit is contained in:
parent
b81926def6
commit
e109a8b502
6 changed files with 196 additions and 33 deletions
|
|
@ -738,6 +738,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||||
# API Server
|
# API Server
|
||||||
api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes")
|
api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes")
|
||||||
api_server_key = os.getenv("API_SERVER_KEY", "")
|
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_port = os.getenv("API_SERVER_PORT")
|
||||||
api_server_host = os.getenv("API_SERVER_HOST")
|
api_server_host = os.getenv("API_SERVER_HOST")
|
||||||
if api_server_enabled or api_server_key:
|
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
|
config.platforms[Platform.API_SERVER].enabled = True
|
||||||
if api_server_key:
|
if api_server_key:
|
||||||
config.platforms[Platform.API_SERVER].extra["key"] = 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:
|
if api_server_port:
|
||||||
try:
|
try:
|
||||||
config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port)
|
config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port)
|
||||||
|
|
@ -786,4 +791,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,6 @@ class ResponseStore:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_CORS_HEADERS = {
|
_CORS_HEADERS = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||||
}
|
}
|
||||||
|
|
@ -105,11 +104,23 @@ _CORS_HEADERS = {
|
||||||
if AIOHTTP_AVAILABLE:
|
if AIOHTTP_AVAILABLE:
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def cors_middleware(request, handler):
|
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":
|
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 = await handler(request)
|
||||||
response.headers.update(_CORS_HEADERS)
|
if cors_headers is not None:
|
||||||
|
response.headers.update(cors_headers)
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
cors_middleware = None # type: ignore[assignment]
|
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._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._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._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._app: Optional["web.Application"] = None
|
||||||
self._runner: Optional["web.AppRunner"] = None
|
self._runner: Optional["web.AppRunner"] = None
|
||||||
self._site: Optional["web.TCPSite"] = None
|
self._site: Optional["web.TCPSite"] = None
|
||||||
|
|
@ -136,6 +150,49 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||||
# Conversation name → latest response_id mapping
|
# Conversation name → latest response_id mapping
|
||||||
self._conversations: Dict[str, str] = {}
|
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
|
# Auth helper
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -903,6 +960,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._app = web.Application(middlewares=[cors_middleware])
|
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("/health", self._handle_health)
|
||||||
self._app.router.add_get("/v1/models", self._handle_models)
|
self._app.router.add_get("/v1/models", self._handle_models)
|
||||||
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
||||||
|
|
|
||||||
|
|
@ -119,22 +119,33 @@ class TestAdapterInit:
|
||||||
def test_custom_config_from_extra(self):
|
def test_custom_config_from_extra(self):
|
||||||
config = PlatformConfig(
|
config = PlatformConfig(
|
||||||
enabled=True,
|
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)
|
adapter = APIServerAdapter(config)
|
||||||
assert adapter._host == "0.0.0.0"
|
assert adapter._host == "0.0.0.0"
|
||||||
assert adapter._port == 9999
|
assert adapter._port == 9999
|
||||||
assert adapter._api_key == "sk-test"
|
assert adapter._api_key == "sk-test"
|
||||||
|
assert adapter._cors_origins == ("http://localhost:3000",)
|
||||||
|
|
||||||
def test_config_from_env(self, monkeypatch):
|
def test_config_from_env(self, monkeypatch):
|
||||||
monkeypatch.setenv("API_SERVER_HOST", "10.0.0.1")
|
monkeypatch.setenv("API_SERVER_HOST", "10.0.0.1")
|
||||||
monkeypatch.setenv("API_SERVER_PORT", "7777")
|
monkeypatch.setenv("API_SERVER_PORT", "7777")
|
||||||
monkeypatch.setenv("API_SERVER_KEY", "sk-env")
|
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)
|
config = PlatformConfig(enabled=True)
|
||||||
adapter = APIServerAdapter(config)
|
adapter = APIServerAdapter(config)
|
||||||
assert adapter._host == "10.0.0.1"
|
assert adapter._host == "10.0.0.1"
|
||||||
assert adapter._port == 7777
|
assert adapter._port == 7777
|
||||||
assert adapter._api_key == "sk-env"
|
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."""
|
"""Create an adapter with optional API key."""
|
||||||
extra = {}
|
extra = {}
|
||||||
if api_key:
|
if api_key:
|
||||||
extra["key"] = api_key
|
extra["key"] = api_key
|
||||||
|
if cors_origins is not None:
|
||||||
|
extra["cors_origins"] = cors_origins
|
||||||
config = PlatformConfig(enabled=True, extra=extra)
|
config = PlatformConfig(enabled=True, extra=extra)
|
||||||
return APIServerAdapter(config)
|
return APIServerAdapter(config)
|
||||||
|
|
||||||
|
|
@ -202,6 +215,7 @@ def _make_adapter(api_key: str = "") -> APIServerAdapter:
|
||||||
def _create_app(adapter: APIServerAdapter) -> web.Application:
|
def _create_app(adapter: APIServerAdapter) -> web.Application:
|
||||||
"""Create the aiohttp app from the adapter (without starting the full server)."""
|
"""Create the aiohttp app from the adapter (without starting the full server)."""
|
||||||
app = web.Application(middlewares=[cors_middleware])
|
app = web.Application(middlewares=[cors_middleware])
|
||||||
|
app["api_server_adapter"] = adapter
|
||||||
app.router.add_get("/health", adapter._handle_health)
|
app.router.add_get("/health", adapter._handle_health)
|
||||||
app.router.add_get("/v1/models", adapter._handle_models)
|
app.router.add_get("/v1/models", adapter._handle_models)
|
||||||
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
|
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("port") == 9999
|
||||||
assert config.platforms[Platform.API_SERVER].extra.get("host") == "0.0.0.0"
|
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):
|
def test_api_server_in_connected_platforms(self):
|
||||||
config = GatewayConfig()
|
config = GatewayConfig()
|
||||||
config.platforms[Platform.API_SERVER] = PlatformConfig(enabled=True)
|
config.platforms[Platform.API_SERVER] = PlatformConfig(enabled=True)
|
||||||
|
|
@ -1156,26 +1183,91 @@ class TestTruncation:
|
||||||
|
|
||||||
|
|
||||||
class TestCORS:
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_cors_headers_on_get(self, adapter):
|
async def test_cors_headers_not_present_by_default(self, adapter):
|
||||||
"""CORS headers present on normal responses."""
|
"""CORS is disabled unless explicitly configured."""
|
||||||
app = _create_app(adapter)
|
app = _create_app(adapter)
|
||||||
async with TestClient(TestServer(app)) as cli:
|
async with TestClient(TestServer(app)) as cli:
|
||||||
resp = await cli.get("/health")
|
resp = await cli.get("/health")
|
||||||
assert resp.status == 200
|
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 "POST" in resp.headers.get("Access-Control-Allow-Methods", "")
|
||||||
assert "DELETE" in resp.headers.get("Access-Control-Allow-Methods", "")
|
assert "DELETE" in resp.headers.get("Access-Control-Allow-Methods", "")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cors_options_preflight(self, adapter):
|
async def test_cors_options_preflight_allowed_for_configured_origin(self):
|
||||||
"""OPTIONS preflight request returns CORS headers."""
|
"""Configured origins can complete browser preflight."""
|
||||||
|
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
|
||||||
app = _create_app(adapter)
|
app = _create_app(adapter)
|
||||||
async with TestClient(TestServer(app)) as cli:
|
async with TestClient(TestServer(app)) as cli:
|
||||||
# OPTIONS to a known path — aiohttp will route through middleware
|
resp = await cli.options(
|
||||||
resp = await cli.options("/health")
|
"/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.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", "")
|
assert "Authorization" in resp.headers.get("Access-Control-Allow-Headers", "")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_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) |
|
| `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_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_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: `~`) |
|
| `MESSAGING_CWD` | Working directory for terminal commands in messaging mode (default: `~`) |
|
||||||
| `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms |
|
| `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms |
|
||||||
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) |
|
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) |
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ Add to `~/.hermes/.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
API_SERVER_ENABLED=true
|
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
|
### 2. Start the gateway
|
||||||
|
|
@ -39,6 +42,7 @@ Point any OpenAI-compatible client at `http://localhost:8642/v1`:
|
||||||
```bash
|
```bash
|
||||||
# Test with curl
|
# Test with curl
|
||||||
curl http://localhost:8642/v1/chat/completions \
|
curl http://localhost:8642/v1/chat/completions \
|
||||||
|
-H "Authorization: Bearer change-me-local-dev" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"model": "hermes-agent", "messages": [{"role": "user", "content": "Hello!"}]}'
|
-d '{"model": "hermes-agent", "messages": [{"role": "user", "content": "Hello!"}]}'
|
||||||
```
|
```
|
||||||
|
|
@ -168,12 +172,12 @@ Bearer token auth via the `Authorization` header:
|
||||||
Authorization: Bearer ***
|
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
|
:::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
|
## 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_PORT` | `8642` | HTTP server port |
|
||||||
| `API_SERVER_HOST` | `127.0.0.1` | Bind address (localhost only by default) |
|
| `API_SERVER_HOST` | `127.0.0.1` | Bind address (localhost only by default) |
|
||||||
| `API_SERVER_KEY` | _(none)_ | Bearer token for auth |
|
| `API_SERVER_KEY` | _(none)_ | Bearer token for auth |
|
||||||
|
| `API_SERVER_CORS_ORIGINS` | _(none)_ | Comma-separated allowed browser origins |
|
||||||
|
|
||||||
### config.yaml
|
### config.yaml
|
||||||
|
|
||||||
|
|
@ -196,7 +201,15 @@ The default bind address (`127.0.0.1`) is safe for local-only use.
|
||||||
|
|
||||||
## CORS
|
## 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
|
## Compatible Frontends
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
## Quick Setup
|
||||||
|
|
||||||
### 1. Enable the API server
|
### 1. Enable the API server
|
||||||
|
|
@ -28,8 +30,7 @@ Add to `~/.hermes/.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
API_SERVER_ENABLED=true
|
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
|
### 2. Start Hermes Agent gateway
|
||||||
|
|
@ -49,7 +50,7 @@ You should see:
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:8080 \
|
docker run -d -p 3000:8080 \
|
||||||
-e OPENAI_API_BASE_URL=http://host.docker.internal:8642/v1 \
|
-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 \
|
--add-host=host.docker.internal:host-gateway \
|
||||||
-v open-webui:/app/backend/data \
|
-v open-webui:/app/backend/data \
|
||||||
--name open-webui \
|
--name open-webui \
|
||||||
|
|
@ -57,12 +58,6 @@ docker run -d -p 3000:8080 \
|
||||||
ghcr.io/open-webui/open-webui:main
|
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
|
### 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!
|
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
|
- open-webui:/app/backend/data
|
||||||
environment:
|
environment:
|
||||||
- OPENAI_API_BASE_URL=http://host.docker.internal:8642/v1
|
- OPENAI_API_BASE_URL=http://host.docker.internal:8642/v1
|
||||||
- OPENAI_API_KEY=not-needed
|
- OPENAI_API_KEY=your-secret-key
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: always
|
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_ENABLED` | `false` | Enable the API server |
|
||||||
| `API_SERVER_PORT` | `8642` | HTTP server port |
|
| `API_SERVER_PORT` | `8642` | HTTP server port |
|
||||||
| `API_SERVER_HOST` | `127.0.0.1` | Bind address |
|
| `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
|
### Open WebUI
|
||||||
|
|
||||||
|
|
@ -195,7 +190,7 @@ Hermes Agent may be executing multiple tool calls (reading files, running comman
|
||||||
|
|
||||||
### "Invalid API key" errors
|
### "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)
|
## Linux Docker (no Docker Desktop)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue