@
feat(api): require api_key when binding to all interfaces (parity with WS gateway) The OpenAI-compatible API server had no authentication option, unlike the WebSocket gateway which already refuses wildcard binds without a token. When bound to 0.0.0.0, any caller who could reach the port could drive the agent with its default tool posture. - Add api_key field to ApiConfig (schema.py). - Add wildcard_host_requires_auth validator that rejects wildcard binds without api_key, mirroring the WS gateway pattern. - Add Bearer-token auth middleware to the API server (server.py). /health remains unauthenticated. - Replace the wildcard-host CLI warning with a hard error when api_key is unset, and pass api_key to create_app. Fixes #4490 @
This commit is contained in:
+27
-1
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hmac
|
||||
import json as _json
|
||||
import time
|
||||
import uuid
|
||||
@@ -392,7 +393,10 @@ async def handle_health(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
def create_app(
|
||||
agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0
|
||||
agent_loop,
|
||||
model_name: str = "nanobot",
|
||||
request_timeout: float = 120.0,
|
||||
api_key: str = "",
|
||||
) -> web.Application:
|
||||
"""Create the aiohttp application.
|
||||
|
||||
@@ -400,6 +404,7 @@ def create_app(
|
||||
agent_loop: An initialized AgentLoop instance.
|
||||
model_name: Model name reported in responses.
|
||||
request_timeout: Per-request timeout in seconds.
|
||||
api_key: Optional API key for Bearer-token authentication.
|
||||
"""
|
||||
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
||||
app["agent_loop"] = agent_loop
|
||||
@@ -407,6 +412,27 @@ def create_app(
|
||||
app["request_timeout"] = request_timeout
|
||||
app["session_locks"] = {} # per-user locks, keyed by session_key
|
||||
|
||||
@web.middleware
|
||||
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
|
||||
if not api_key:
|
||||
return await handler(request)
|
||||
# Allow unauthenticated health checks.
|
||||
if request.path == "/health":
|
||||
return await handler(request)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return web.json_response(
|
||||
{"error": "Missing Authorization header. Use: Bearer <api_key>"},
|
||||
status=401,
|
||||
)
|
||||
if not hmac.compare_digest(auth[len("Bearer "):], api_key):
|
||||
return web.json_response(
|
||||
{"error": "Invalid API key"}, status=401,
|
||||
)
|
||||
return await handler(request)
|
||||
|
||||
app.middlewares.append(auth_middleware)
|
||||
|
||||
app.router.add_post("/v1/chat/completions", handle_chat_completions)
|
||||
app.router.add_get("/v1/models", handle_models)
|
||||
app.router.add_get("/health", handle_health)
|
||||
|
||||
+13
-3
@@ -798,14 +798,24 @@ def serve(
|
||||
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
|
||||
console.print(" [cyan]Session[/cyan] : api:default")
|
||||
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
||||
api_key = api_cfg.api_key.strip() if api_cfg.api_key else ""
|
||||
if host in {"0.0.0.0", "::"}:
|
||||
if not api_key:
|
||||
console.print(
|
||||
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
|
||||
"Set api.api_key in config to prevent unauthenticated access.[/red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] API is bound to all interfaces. "
|
||||
"Only do this behind a trusted network boundary, firewall, or reverse proxy."
|
||||
"[yellow]API is bound to all interfaces "
|
||||
"(authentication required).[/yellow]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout)
|
||||
api_app = create_app(
|
||||
agent_loop, model_name=model_name, request_timeout=timeout,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
async def on_startup(_app):
|
||||
await agent_loop._connect_mcp()
|
||||
|
||||
@@ -307,6 +307,18 @@ class ApiConfig(Base):
|
||||
host: str = "127.0.0.1" # Safer default: local-only bind.
|
||||
port: int = 8900
|
||||
timeout: float = 120.0 # Per-request timeout in seconds.
|
||||
api_key: str = Field(default="", repr=False)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def wildcard_host_requires_auth(self) -> "ApiConfig":
|
||||
if self.host not in ("0.0.0.0", "::"):
|
||||
return self
|
||||
if self.api_key.strip():
|
||||
return self
|
||||
raise ValueError(
|
||||
"host is 0.0.0.0 (all interfaces) but api_key is not set "
|
||||
"- set api.api_key to prevent unauthenticated access"
|
||||
)
|
||||
|
||||
|
||||
class GatewayConfig(Base):
|
||||
|
||||
Reference in New Issue
Block a user