From 883776358ea164229a613cbf28c47f2ec81b9584 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 7 Jul 2026 16:41:59 +0800 Subject: [PATCH] fix: keep local api serve unauthenticated maintainer edit: Align OpenAI-compatible API auth with the WebSocket channel boundary: loopback serve remains usable without a key, while wildcard binds still fail before agent initialization unless api.api_key is configured. --- SECURITY.md | 2 +- docs/cli-reference.md | 2 +- docs/openai-api.md | 28 ++++++++++------------------ nanobot/api/server.py | 4 ++-- nanobot/cli/commands.py | 6 +++--- tests/cli/test_commands.py | 9 ++++----- tests/test_openai_api.py | 9 ++++----- 7 files changed, 25 insertions(+), 35 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 436496e0..e126d36a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -107,7 +107,7 @@ File operations have path traversal protection, but: **API Calls:** - All external API calls use HTTPS by default - Timeouts are configured to prevent hanging requests -- The OpenAI-compatible API server requires `api.api_key` for API routes; only `/health` remains unauthenticated for probes and load balancers +- The OpenAI-compatible API server must set `api.api_key` when binding to `0.0.0.0` or `::`; otherwise startup fails to prevent unauthenticated network access - Consider using a firewall to restrict outbound connections if needed **WhatsApp:** diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fc69ffe9..27c7f010 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -204,7 +204,7 @@ Default API endpoint: http://127.0.0.1:8900 ``` -`nanobot serve` requires `api.apiKey`; send it as a Bearer token on API routes. +Public binds (`0.0.0.0` or `::`) require `api.apiKey`; send it as a Bearer token on API routes. See [`openai-api.md`](./openai-api.md) for request examples. diff --git a/docs/openai-api.md b/docs/openai-api.md index 062ead39..f31e7e42 100644 --- a/docs/openai-api.md +++ b/docs/openai-api.md @@ -5,32 +5,33 @@ nanobot can expose a minimal OpenAI-compatible endpoint for local integrations: ```bash nanobot plugins enable api nanobot agent -m "Hello!" -# Set api.apiKey first; see Authentication below. nanobot serve ``` -Run the CLI check first. If `nanobot agent -m "Hello!"` fails, fix provider or config setup before debugging the API server. By default, the API binds to `127.0.0.1:8900`. You can change this in `config.json`. `nanobot serve` requires `api.apiKey`; set it before starting the server. +Run the CLI check first. If `nanobot agent -m "Hello!"` fails, fix provider or config setup before debugging the API server. By default, the API binds to `127.0.0.1:8900`. You can change this in `config.json`. For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). ## Authentication -`nanobot serve` requires `api.apiKey` for all API binds. Without it, startup -fails before the agent is initialized. Keep the key secret and send it as a -Bearer token on API routes. +Local-only `127.0.0.1` usage does not require an API key. If you bind the API +server to all interfaces with `api.host: "0.0.0.0"` or `"::"`, nanobot requires +`api.apiKey`; otherwise startup fails to avoid exposing an unauthenticated agent +endpoint on the network. ```json { "api": { - "host": "127.0.0.1", + "host": "0.0.0.0", "port": 8900, "apiKey": "${NANOBOT_API_KEY}" } } ``` -The health endpoint remains unauthenticated so local probes and load balancers -can still check process health. +When `api.apiKey` is set, send it as a Bearer token on API routes. The health +endpoint remains unauthenticated so local probes and load balancers can still +check process health. ```bash curl http://127.0.0.1:8900/v1/models \ @@ -69,7 +70,6 @@ If `channel` points to a channel that is not enabled in your config, nanobot wil ```bash curl http://127.0.0.1:8900/v1/chat/completions \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer $NANOBOT_API_KEY" \ -d '{ "messages": [{"role": "user", "content": "hi"}], "session_id": "my-session" @@ -83,7 +83,6 @@ Send images inline using the OpenAI multimodal content format: ```bash curl http://127.0.0.1:8900/v1/chat/completions \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer $NANOBOT_API_KEY" \ -d '{ "messages": [{"role": "user", "content": [ {"type": "text", "text": "Describe this image"}, @@ -99,13 +98,11 @@ Upload any supported file type (images, PDF, Word, Excel, PPT) via multipart: ```bash # Single file curl http://127.0.0.1:8900/v1/chat/completions \ - -H "Authorization: Bearer $NANOBOT_API_KEY" \ -F "message=Summarize this report" \ -F "files=@report.docx" # Multiple files with session isolation curl http://127.0.0.1:8900/v1/chat/completions \ - -H "Authorization: Bearer $NANOBOT_API_KEY" \ -F "message=Compare these files" \ -F "files=@chart.png" \ -F "files=@data.xlsx" \ @@ -120,13 +117,10 @@ Supported file types: ## Python (`requests`) ```python -import os - import requests resp = requests.post( "http://127.0.0.1:8900/v1/chat/completions", - headers={"Authorization": f"Bearer {os.environ['NANOBOT_API_KEY']}"}, json={ "messages": [{"role": "user", "content": "hi"}], "session_id": "my-session", # optional: isolate conversation @@ -140,13 +134,11 @@ print(resp.json()["choices"][0]["message"]["content"]) ## Python (`openai`) ```python -import os - from openai import OpenAI client = OpenAI( base_url="http://127.0.0.1:8900/v1", - api_key=os.environ["NANOBOT_API_KEY"], + api_key="dummy", ) resp = client.chat.completions.create( diff --git a/nanobot/api/server.py b/nanobot/api/server.py index 2dbef858..ae639d75 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -404,7 +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: API key for Bearer-token authentication on API routes. + api_key: Optional API key for Bearer-token authentication on API routes. """ app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images app["agent_loop"] = agent_loop @@ -418,7 +418,7 @@ def create_app( if request.path == "/health": return await handler(request) if not api_key: - return _error_json(401, "API key is not configured") + return await handler(request) auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return _error_json(401, "Missing Authorization header. Use: Bearer ") diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 566ee345..0a35fc9a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1130,10 +1130,10 @@ def serve( port = port if port is not None else api_cfg.port timeout = timeout if timeout is not None else api_cfg.timeout api_key = api_cfg.api_key.strip() if api_cfg.api_key else "" - if not api_key: + if host in {"0.0.0.0", "::"} and not api_key: console.print( - "[red]Error: api_key is not set. " - "Set api.api_key in config to prevent unauthenticated API access.[/red]" + "[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) sync_workspace_templates(runtime_config.workspace_path) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index bec49340..48936914 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -2890,7 +2890,7 @@ def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> N assert seen["api_key"] == "secret" -def test_serve_rejects_loopback_without_api_key(monkeypatch, tmp_path: Path) -> None: +def test_serve_allows_loopback_without_api_key(monkeypatch, tmp_path: Path) -> None: config_file = _write_instance_config(tmp_path) config = Config() seen: dict[str, object] = {} @@ -2899,10 +2899,9 @@ def test_serve_rejects_loopback_without_api_key(monkeypatch, tmp_path: Path) -> result = runner.invoke(app, ["serve", "--config", str(config_file)]) - assert result.exit_code == 1 - assert "api_key is not set" in result.stdout - assert "workspace" not in seen - assert "api_app" not in seen + assert result.exit_code == 0 + assert seen["host"] == "127.0.0.1" + assert seen["api_key"] == "" def test_serve_passes_configured_api_key(monkeypatch, tmp_path: Path) -> None: diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index 5e652cb6..4c2056cf 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -132,7 +132,7 @@ async def test_api_key_protects_api_routes_but_not_health(aiohttp_client, mock_a @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.asyncio -async def test_api_routes_fail_closed_without_configured_api_key(aiohttp_client, mock_agent) -> None: +async def test_api_routes_allow_requests_without_configured_api_key(aiohttp_client, mock_agent) -> None: app = create_app(mock_agent, model_name="test-model") client = await aiohttp_client(app) @@ -144,10 +144,9 @@ async def test_api_routes_fail_closed_without_configured_api_key(aiohttp_client, ) assert health.status == 200 - assert models.status == 401 - assert chat.status == 401 - assert (await models.json())["error"]["message"] == "API key is not configured" - mock_agent.process_direct.assert_not_called() + assert models.status == 200 + assert chat.status == 200 + mock_agent.process_direct.assert_called_once() @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")