From 56443ac6e2b69913e5ec4245c47eeab6fb19a85a Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sat, 27 Jun 2026 03:57:09 +0800 Subject: [PATCH] @ 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 @ --- nanobot/api/server.py | 28 +++++++++++++++++++++++++++- nanobot/cli/commands.py | 16 +++++++++++++--- nanobot/config/schema.py | 12 ++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/nanobot/api/server.py b/nanobot/api/server.py index be3f58c9..68452452 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -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 "}, + 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) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b3da8b78..89ffdfcd 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 5b50407e..78aa12b2 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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):