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.
This commit is contained in:
chengyongru 2026-07-07 16:41:59 +08:00 committed by Xubin Ren
parent 28141ce20b
commit 883776358e
7 changed files with 25 additions and 35 deletions

View File

@ -107,7 +107,7 @@ File operations have path traversal protection, but:
**API Calls:** **API Calls:**
- All external API calls use HTTPS by default - All external API calls use HTTPS by default
- Timeouts are configured to prevent hanging requests - 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 - Consider using a firewall to restrict outbound connections if needed
**WhatsApp:** **WhatsApp:**

View File

@ -204,7 +204,7 @@ Default API endpoint:
http://127.0.0.1:8900 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. See [`openai-api.md`](./openai-api.md) for request examples.

View File

@ -5,32 +5,33 @@ nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
```bash ```bash
nanobot plugins enable api nanobot plugins enable api
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
# Set api.apiKey first; see Authentication below.
nanobot serve 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). For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md).
## Authentication ## Authentication
`nanobot serve` requires `api.apiKey` for all API binds. Without it, startup Local-only `127.0.0.1` usage does not require an API key. If you bind the API
fails before the agent is initialized. Keep the key secret and send it as a server to all interfaces with `api.host: "0.0.0.0"` or `"::"`, nanobot requires
Bearer token on API routes. `api.apiKey`; otherwise startup fails to avoid exposing an unauthenticated agent
endpoint on the network.
```json ```json
{ {
"api": { "api": {
"host": "127.0.0.1", "host": "0.0.0.0",
"port": 8900, "port": 8900,
"apiKey": "${NANOBOT_API_KEY}" "apiKey": "${NANOBOT_API_KEY}"
} }
} }
``` ```
The health endpoint remains unauthenticated so local probes and load balancers When `api.apiKey` is set, send it as a Bearer token on API routes. The health
can still check process health. endpoint remains unauthenticated so local probes and load balancers can still
check process health.
```bash ```bash
curl http://127.0.0.1:8900/v1/models \ 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 ```bash
curl http://127.0.0.1:8900/v1/chat/completions \ curl http://127.0.0.1:8900/v1/chat/completions \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer $NANOBOT_API_KEY" \
-d '{ -d '{
"messages": [{"role": "user", "content": "hi"}], "messages": [{"role": "user", "content": "hi"}],
"session_id": "my-session" "session_id": "my-session"
@ -83,7 +83,6 @@ Send images inline using the OpenAI multimodal content format:
```bash ```bash
curl http://127.0.0.1:8900/v1/chat/completions \ curl http://127.0.0.1:8900/v1/chat/completions \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer $NANOBOT_API_KEY" \
-d '{ -d '{
"messages": [{"role": "user", "content": [ "messages": [{"role": "user", "content": [
{"type": "text", "text": "Describe this image"}, {"type": "text", "text": "Describe this image"},
@ -99,13 +98,11 @@ Upload any supported file type (images, PDF, Word, Excel, PPT) via multipart:
```bash ```bash
# Single file # Single file
curl http://127.0.0.1:8900/v1/chat/completions \ curl http://127.0.0.1:8900/v1/chat/completions \
-H "Authorization: Bearer $NANOBOT_API_KEY" \
-F "message=Summarize this report" \ -F "message=Summarize this report" \
-F "files=@report.docx" -F "files=@report.docx"
# Multiple files with session isolation # Multiple files with session isolation
curl http://127.0.0.1:8900/v1/chat/completions \ curl http://127.0.0.1:8900/v1/chat/completions \
-H "Authorization: Bearer $NANOBOT_API_KEY" \
-F "message=Compare these files" \ -F "message=Compare these files" \
-F "files=@chart.png" \ -F "files=@chart.png" \
-F "files=@data.xlsx" \ -F "files=@data.xlsx" \
@ -120,13 +117,10 @@ Supported file types:
## Python (`requests`) ## Python (`requests`)
```python ```python
import os
import requests import requests
resp = requests.post( resp = requests.post(
"http://127.0.0.1:8900/v1/chat/completions", "http://127.0.0.1:8900/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['NANOBOT_API_KEY']}"},
json={ json={
"messages": [{"role": "user", "content": "hi"}], "messages": [{"role": "user", "content": "hi"}],
"session_id": "my-session", # optional: isolate conversation "session_id": "my-session", # optional: isolate conversation
@ -140,13 +134,11 @@ print(resp.json()["choices"][0]["message"]["content"])
## Python (`openai`) ## Python (`openai`)
```python ```python
import os
from openai import OpenAI from openai import OpenAI
client = OpenAI( client = OpenAI(
base_url="http://127.0.0.1:8900/v1", base_url="http://127.0.0.1:8900/v1",
api_key=os.environ["NANOBOT_API_KEY"], api_key="dummy",
) )
resp = client.chat.completions.create( resp = client.chat.completions.create(

View File

@ -404,7 +404,7 @@ def create_app(
agent_loop: An initialized AgentLoop instance. agent_loop: An initialized AgentLoop instance.
model_name: Model name reported in responses. model_name: Model name reported in responses.
request_timeout: Per-request timeout in seconds. 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 = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
app["agent_loop"] = agent_loop app["agent_loop"] = agent_loop
@ -418,7 +418,7 @@ def create_app(
if request.path == "/health": if request.path == "/health":
return await handler(request) return await handler(request)
if not api_key: if not api_key:
return _error_json(401, "API key is not configured") return await handler(request)
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "): if not auth.startswith("Bearer "):
return _error_json(401, "Missing Authorization header. Use: Bearer <api_key>") return _error_json(401, "Missing Authorization header. Use: Bearer <api_key>")

View File

@ -1130,10 +1130,10 @@ def serve(
port = port if port is not None else api_cfg.port port = port if port is not None else api_cfg.port
timeout = timeout if timeout is not None else api_cfg.timeout timeout = timeout if timeout is not None else api_cfg.timeout
api_key = api_cfg.api_key.strip() if api_cfg.api_key else "" 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( console.print(
"[red]Error: api_key is not set. " "[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 API access.[/red]" "Set api.api_key in config to prevent unauthenticated access.[/red]"
) )
raise typer.Exit(1) raise typer.Exit(1)
sync_workspace_templates(runtime_config.workspace_path) sync_workspace_templates(runtime_config.workspace_path)

View File

@ -2890,7 +2890,7 @@ def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> N
assert seen["api_key"] == "secret" 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_file = _write_instance_config(tmp_path)
config = Config() config = Config()
seen: dict[str, object] = {} 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)]) result = runner.invoke(app, ["serve", "--config", str(config_file)])
assert result.exit_code == 1 assert result.exit_code == 0
assert "api_key is not set" in result.stdout assert seen["host"] == "127.0.0.1"
assert "workspace" not in seen assert seen["api_key"] == ""
assert "api_app" not in seen
def test_serve_passes_configured_api_key(monkeypatch, tmp_path: Path) -> None: def test_serve_passes_configured_api_key(monkeypatch, tmp_path: Path) -> None:

View File

@ -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.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio @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") app = create_app(mock_agent, model_name="test-model")
client = await aiohttp_client(app) 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 health.status == 200
assert models.status == 401 assert models.status == 200
assert chat.status == 401 assert chat.status == 200
assert (await models.json())["error"]["message"] == "API key is not configured" mock_agent.process_direct.assert_called_once()
mock_agent.process_direct.assert_not_called()
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")