diff --git a/docs/deployment.md b/docs/deployment.md index 746c3521865..aa8a7fed2f7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -168,3 +168,32 @@ launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.nanobot.gateway.plist After editing the plist, run `launchctl bootout ...` and `launchctl bootstrap ...` again. > **Note:** if startup fails with "address already in use", stop the manually started `nanobot gateway` process first. + +## Exposing nanobot publicly + +nanobot is built for **one user, one machine, one long chat**. Publishing it through a public tunnel or reverse proxy works, but the trust boundary is not nanobot — it's whatever fronts it. + +> [!CAUTION] +> Do **not** put the WebUI or `nanobot serve` behind a public tunnel without real authentication in front of `/`, `/webui/bootstrap`, `/api/*`, and the WebSocket upgrade path. nanobot does not authenticate human users for the embedded WebUI. + +For public WebSocket use, expose **only** the WS path with: + +- `webuiBootstrapDisabled: true` (or bind to a non-loopback host, which disables bootstrap automatically) +- `websocketRequiresToken: true` +- A strong `tokenIssueSecret` +- TLS in front (terminated by the proxy or via `sslCertfile` / `sslKeyfile`) +- A real auth boundary at the proxy (basic auth, OAuth, mTLS, IP allowlist) + +For the OpenAI-compatible API (`nanobot serve`): + +- Set a strong `api.authToken` (or pass `--auth-token`); requests to `/v1/*` will then require `Authorization: Bearer `. +- The CLI **refuses to start** when `--host` is non-loopback and no auth token is configured. +- `/health` stays open so liveness probes don't need credentials. + +### Why nanobot fails loud on unsafe configs + +Heuristics (peer-IP, `X-Forwarded-For`) are easy to defeat with a custom proxy that strips or omits headers. nanobot therefore makes the unsafe combinations a hard error at startup rather than relying on heuristics: + +- WebSocket channel: refuses to start when `host` is not loopback and either `tokenIssuePath` is set without `tokenIssueSecret`, or `websocketRequiresToken: false` with no static `token`. +- WebUI bootstrap: refuses to mint a token when the channel is bound to a non-loopback host, when `webuiBootstrapDisabled: true`, when the TCP peer isn't loopback, or when standard proxy-hop headers are present. +- `nanobot serve`: refuses to start when the bind is non-loopback and no `api.authToken` is set. diff --git a/docs/websocket.md b/docs/websocket.md index e3303b8682b..7c6b9163db6 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -187,6 +187,15 @@ All fields go under `channels.websocket` in `config.json`. | `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). | | `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain tokens (logged as a warning). | | `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). | +| `webuiBootstrapDisabled` | bool | `false` | Hard-disable the implicit `/webui/bootstrap` token mint. Set to `true` whenever nanobot sits behind a reverse proxy or tunnel — the bootstrap was always meant for same-machine use, and proxy hops can defeat peer-IP / header heuristics. With it disabled, expose the WS path with a static `token` or the `tokenIssuePath` + `tokenIssueSecret` flow instead. | + +> [!WARNING] +> Public-tunnel deployments. nanobot is built around a single-user, single-instance deployment model. If you publish nanobot through a tunnel or reverse proxy: +> +> - Do **not** rely on `/webui/bootstrap` — set `webuiBootstrapDisabled: true` (or bind to a non-loopback host, which disables it automatically). +> - Set `websocketRequiresToken: true` and a strong `tokenIssueSecret`. +> - Terminate TLS in front, and require real authentication (basic auth, OAuth, mTLS, IP allowlist) at the proxy — nanobot itself does not authenticate human users for the WebUI. +> - Refuses-to-start guards: the channel will fail loud at startup if `host` is not loopback and either `tokenIssuePath` is set without `tokenIssueSecret`, or `websocketRequiresToken=false` with no static `token`. ### Access Control diff --git a/nanobot/api/server.py b/nanobot/api/server.py index 0a79fa09763..53ce8bfc98f 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -378,8 +378,85 @@ async def handle_health(request: web.Request) -> web.Response: # --------------------------------------------------------------------------- +_ALLOWED_V1_CONTENT_TYPES = ("application/json", "multipart/form-data") + + +def _is_origin_allowed(origin: str, allowed: tuple[str, ...]) -> bool: + if not origin: + return True + return origin in allowed + + +@web.middleware +async def _browser_csrf_middleware( + request: web.Request, handler: Any +) -> web.StreamResponse: + """Block browser-driven CSRF on /v1/* even when no bearer is configured. + + Browsers can send `Content-Type: text/plain` POSTs without a CORS preflight, + which would otherwise let any visited page drive a default `nanobot serve` + on loopback. Two cheap defenses, applied only to mutating /v1/* requests: + + 1. Reject `Origin` headers that aren't in the configured allowlist. Real + OpenAI clients (curl, openai-python, LiteLLM) don't send Origin; + browsers always do on cross-origin POSTs. + 2. Require Content-Type `application/json` or `multipart/form-data`. + This forces a CORS preflight for any browser request, which we never + answer. + + /health and GETs (e.g. /v1/models) are exempt — they have no side effects. + """ + if request.method != "POST" or not request.path.startswith("/v1/"): + return await handler(request) + + allowed_origins: tuple[str, ...] = request.app.get("allowed_origins", ()) + origin = request.headers.get("Origin", "") + if origin and not _is_origin_allowed(origin, allowed_origins): + return _error_json( + 403, + "Cross-origin requests are not allowed", + err_type="forbidden", + ) + + content_type = (request.content_type or "").lower() + if not any(content_type.startswith(ct) for ct in _ALLOWED_V1_CONTENT_TYPES): + return _error_json( + 415, + "Unsupported Content-Type; expected application/json or multipart/form-data", + err_type="invalid_request_error", + ) + return await handler(request) + + +@web.middleware +async def _bearer_auth_middleware( + request: web.Request, handler: Any +) -> web.StreamResponse: + """Require ``Authorization: Bearer `` on /v1/* when configured. + + /health is intentionally exempt so liveness probes don't need credentials. + Disabled (no-op) when ``auth_token`` is empty. + """ + auth_token: str = request.app.get("auth_token", "") or "" + if not auth_token or not request.path.startswith("/v1/"): + return await handler(request) + header = request.headers.get("Authorization", "") + if not header.lower().startswith("bearer "): + return _error_json(401, "Missing bearer token", err_type="authentication_error") + import hmac as _hmac + + supplied = header[7:].strip() + if not supplied or not _hmac.compare_digest(supplied, auth_token): + return _error_json(401, "Invalid bearer token", err_type="authentication_error") + return await handler(request) + + 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, + auth_token: str = "", + allowed_origins: tuple[str, ...] | list[str] | None = None, ) -> web.Application: """Create the aiohttp application. @@ -387,11 +464,23 @@ def create_app( agent_loop: An initialized AgentLoop instance. model_name: Model name reported in responses. request_timeout: Per-request timeout in seconds. + auth_token: If non-empty, /v1/* requests must carry + ``Authorization: Bearer ``. /health stays open so + liveness probes don't need credentials. + allowed_origins: Browser origins permitted to POST to /v1/*. Empty + (default) means any request carrying an `Origin` header is + rejected — which closes browser-localhost CSRF without affecting + non-browser clients (curl, openai-python, LiteLLM). """ - 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 + middlewares=[_browser_csrf_middleware, _bearer_auth_middleware], + ) app["agent_loop"] = agent_loop app["model_name"] = model_name app["request_timeout"] = request_timeout + app["auth_token"] = auth_token + app["allowed_origins"] = tuple(allowed_origins or ()) app["session_locks"] = {} # per-user locks, keyed by session_key app.router.add_post("/v1/chat/completions", handle_chat_completions) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index eba9ed79a86..2a26ff0e2f7 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -9,6 +9,7 @@ import hashlib import hmac import http +import ipaddress import json import mimetypes import re @@ -91,6 +92,13 @@ class WebSocketConfig(Base): token_issue_secret: str = "" token_ttl_s: int = Field(default=300, ge=30, le=86_400) websocket_requires_token: bool = True + # Hard-disable the implicit ``/webui/bootstrap`` token mint. Set to True + # when nanobot is reachable through a proxy/tunnel: bootstrap was always + # meant for same-machine same-user use, and proxy hops can defeat the + # peer-IP / header heuristics. With bootstrap disabled, expose the WS + # path with an explicit ``token`` (static) or ``token_issue_path`` + + # ``token_issue_secret`` flow instead. + webui_bootstrap_disabled: bool = False allow_from: list[str] = Field(default_factory=lambda: ["*"]) streaming: bool = True # Default 36 MB, upper 40 MB: supports up to 4 images at ~6 MB each after @@ -282,18 +290,67 @@ def _decode_api_key(raw_key: str) -> str | None: return key -def _is_localhost(connection: Any) -> bool: - """Return True if *connection* originated from the loopback interface.""" +# Headers that betray a proxy hop. If any are present the request did not +# originate from a true local user, even when the TCP peer happens to be +# 127.0.0.1 because a reverse proxy or tunnel is forwarding traffic on the +# same host. Treat their presence as proof-of-non-local. +_PROXY_HOP_HEADERS = ( + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "X-Real-IP", + "Forwarded", +) + + +def _has_proxy_hop_header(headers: Any) -> bool: + """Return True if *headers* carries any well-known proxy-hop header.""" + if headers is None: + return False + for name in _PROXY_HOP_HEADERS: + if headers.get(name) or headers.get(name.lower()): + return True + return False + + +def _is_loopback_host(host: str) -> bool: + """Return True if *host* is a true loopback address. + + Accepts ``localhost``, the full IPv4 ``127.0.0.0/8`` range, ``::1``, and + IPv4-mapped IPv6 forms like ``::ffff:127.0.0.5``. Hostnames other than + ``localhost`` resolve to False — DNS could legitimately point them + anywhere, so the conservative default is to treat them as public. + """ + if not host: + return False + h = host.strip().lower() + if h == "localhost": + return True + try: + return ipaddress.ip_address(h).is_loopback + except ValueError: + return False + + +def _is_localhost(connection: Any, request: Any | None = None) -> bool: + """Return True if *connection* originated from the loopback interface. + + When *request* is provided, also fail closed if any standard proxy-hop + header is present: a reverse proxy / tunnel running on the same host + will have a 127.0.0.1 TCP peer even though the real client is remote, + so trusting the peer alone is unsafe. + """ addr = getattr(connection, "remote_address", None) if not addr: return False host = addr[0] if isinstance(addr, tuple) else addr if not isinstance(host, str): return False - # ``::ffff:127.0.0.1`` is loopback in IPv6-mapped form. - if host.startswith("::ffff:"): - host = host[7:] - return host in _LOCALHOSTS + if not _is_loopback_host(host): + return False + if request is not None and _has_proxy_hop_header(request.headers): + return False + return True def _http_response( @@ -533,7 +590,7 @@ async def _dispatch_http(self, connection: Any, request: WsRequest) -> Any: # 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI. if got == "/webui/bootstrap": - return self._handle_webui_bootstrap(connection) + return self._handle_webui_bootstrap(connection, request) # 3. REST surface for the embedded UI. if got == "/api/sessions": @@ -606,8 +663,28 @@ def _purge_expired_api_tokens(self) -> None: if now > expiry: self._api_tokens.pop(token_key, None) - def _handle_webui_bootstrap(self, connection: Any) -> Response: - if not _is_localhost(connection): + def _handle_webui_bootstrap(self, connection: Any, request: WsRequest) -> Response: + # Belt and suspenders: bootstrap is the most powerful unauthenticated + # route on the channel — a successful response mints a token that + # authorizes WS handshakes and the /api/sessions read/delete surface. + # Refuse unless every signal says the request is truly local: + # + # 1. Operator hasn't explicitly disabled the bootstrap (set when + # running behind a proxy/tunnel that can defeat heuristics). + # 2. The channel is bound to a loopback address (so no external + # caller can reach the route at all on a clean install). + # 3. The TCP peer is loopback (catches multi-bound deployments). + # 4. No standard proxy-hop header is present (catches the common + # reverse-proxy / cloudflared / ngrok shape). + if self.config.webui_bootstrap_disabled: + return _http_error(403, "webui bootstrap is disabled") + if not _is_loopback_host(self.config.host): + return _http_error( + 403, + "webui bootstrap requires a loopback bind; use a static token " + "or token_issue_path/token_issue_secret instead", + ) + if not _is_localhost(connection, request): return _http_error(403, "webui bootstrap is localhost-only") # Cap outstanding tokens to avoid runaway growth from a misbehaving client. self._purge_expired_issued_tokens() @@ -956,7 +1033,38 @@ def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[ self._take_issued_token_if_valid(supplied) return None + def _check_public_bind_safety(self) -> None: + """Refuse to start when the bind is non-loopback and no auth gate is set. + + Catches two footguns: + - ``token_issue_path`` exposed without ``token_issue_secret`` lets any + public caller mint connection tokens. + - ``websocket_requires_token=False`` with no static ``token`` lets any + public caller open a chat session unauthenticated. + """ + host = self.config.host.strip() + if not host or _is_loopback_host(host): + return + issue_path = self.config.token_issue_path.strip() + issue_secret = self.config.token_issue_secret.strip() + if issue_path and not issue_secret: + raise ValueError( + "websocket: refusing to start: host={!r} is not loopback and " + "token_issue_path is set without token_issue_secret. Set " + "token_issue_secret to a strong value, or restrict host to " + "127.0.0.1 / ::1.".format(host) + ) + static_token = self.config.token.strip() + if not static_token and not self.config.websocket_requires_token: + raise ValueError( + "websocket: refusing to start: host={!r} is not loopback and " + "no token gate is configured. Set websocketRequiresToken=true " + "(with token_issue_path/token_issue_secret) or set a static " + "token, or restrict host to 127.0.0.1 / ::1.".format(host) + ) + async def start(self) -> None: + self._check_public_bind_safety() self._running = True self._stop_event = asyncio.Event() diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 23f409b5398..e53074d6ce2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -472,11 +472,48 @@ def _migrate_cron_store(config: "Config") -> None: # ============================================================================ +def _is_loopback_bind_host(host: str) -> bool: + """Recognize the full loopback range, not just the three common literals. + + Accepts ``localhost``, the IPv4 ``127.0.0.0/8`` block, ``::1``, and + IPv4-mapped IPv6 forms. Hostnames other than ``localhost`` resolve to + False — DNS could legitimately point them anywhere. + """ + import ipaddress + + if not host: + return False + h = host.strip().lower() + if h == "localhost": + return True + try: + return ipaddress.ip_address(h).is_loopback + except ValueError: + return False + + @app.command() def serve( port: int | None = typer.Option(None, "--port", "-p", help="API server port"), host: str | None = typer.Option(None, "--host", "-H", help="Bind address"), timeout: float | None = typer.Option(None, "--timeout", "-t", help="Per-request timeout (seconds)"), + auth_token: str | None = typer.Option( + None, + "--auth-token", + help=( + "Bearer token required on /v1/* requests. Required when bind is " + "non-loopback. Reads api.authToken from config when omitted." + ), + ), + allowed_origin: list[str] | None = typer.Option( + None, + "--allowed-origin", + help=( + "Browser Origin permitted to POST to /v1/*. Repeat to allow " + "multiple. Default rejects any request carrying an `Origin` " + "header to close browser-localhost CSRF." + ), + ), verbose: bool = typer.Option(False, "--verbose", "-v", help="Show nanobot runtime logs"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -504,6 +541,20 @@ def serve( host = host if host is not None else api_cfg.host port = port if port is not None else api_cfg.port timeout = timeout if timeout is not None else api_cfg.timeout + resolved_auth_token = ( + auth_token if auth_token is not None else api_cfg.auth_token + ) or "" + resolved_auth_token = resolved_auth_token.strip() + resolved_allowed_origins = tuple( + allowed_origin if allowed_origin is not None else api_cfg.allowed_origins + ) + if not _is_loopback_bind_host(host) and not resolved_auth_token: + console.print( + "[red]Error:[/red] API host {!r} is not loopback and no auth token " + "is set. Pass --auth-token= or set api.authToken in config " + "(or restrict --host to 127.0.0.1).".format(host) + ) + raise typer.Exit(2) sync_workspace_templates(runtime_config.workspace_path) bus = MessageBus() provider = _make_provider(runtime_config) @@ -544,9 +595,17 @@ def serve( "[yellow]Warning:[/yellow] API is bound to all interfaces. " "Only do this behind a trusted network boundary, firewall, or reverse proxy." ) + if resolved_auth_token: + console.print(" [cyan]Auth[/cyan] : bearer token required on /v1/*") 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, + auth_token=resolved_auth_token, + allowed_origins=resolved_allowed_origins, + ) async def on_startup(_app): await agent_loop._connect_mcp() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index be4eb7202aa..0d92b8f3ac7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -177,6 +177,15 @@ 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. + # Bearer token required on /v1/* when the API is reachable beyond loopback. + # Empty disables auth (only safe when the bind is loopback or another + # trust boundary fronts the server). The CLI refuses non-loopback binds + # without a token to make the unsafe case loud. + auth_token: str = "" + # Browser origins permitted to POST to /v1/*. Empty (default) rejects any + # request that carries an `Origin` header — closes browser-localhost CSRF + # without affecting non-browser clients (curl, openai-python, LiteLLM). + allowed_origins: list[str] = Field(default_factory=list) class GatewayConfig(Base): diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 51fd50f4a45..5ee74191b26 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -2,7 +2,6 @@ import asyncio import functools -import json from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -359,6 +358,88 @@ async def test_unknown_route_returns_404(bus: MagicMock) -> None: await server_task +@pytest.mark.asyncio +async def test_bootstrap_rejects_x_forwarded_for( + bus: MagicMock, tmp_path: Path +) -> None: + """A reverse-proxy / tunnel running on the same host has TCP peer 127.0.0.1 + even when the real client is remote. Standard proxy hop headers prove the + request is not truly local — bootstrap must refuse rather than mint a + token that authorizes WS + /api/sessions read/delete.""" + sm = _seed_session(tmp_path) + channel = _ch(bus, session_manager=sm, port=29911) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + resp = await _http_get( + "http://127.0.0.1:29911/webui/bootstrap", + headers={"X-Forwarded-For": "203.0.113.5"}, + ) + assert resp.status_code == 403 + # Token pool must stay empty — no leak even when refused. + assert channel._issued_tokens == {} + assert channel._api_tokens == {} + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_bootstrap_rejects_forwarded_header( + bus: MagicMock, tmp_path: Path +) -> None: + """RFC 7239 ``Forwarded:`` is the modern proxy-hop header. Same gate.""" + sm = _seed_session(tmp_path) + channel = _ch(bus, session_manager=sm, port=29912) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + resp = await _http_get( + "http://127.0.0.1:29912/webui/bootstrap", + headers={"Forwarded": 'for="203.0.113.5";proto=https'}, + ) + assert resp.status_code == 403 + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_start_refuses_public_token_issue_without_secret( + bus: MagicMock, +) -> None: + """A non-loopback bind that exposes ``token_issue_path`` without a secret + would let any public caller mint connection tokens — refuse to start.""" + channel = _ch( + bus, + host="0.0.0.0", + port=29913, + tokenIssuePath="/issue", + tokenIssueSecret="", + websocketRequiresToken=True, + ) + with pytest.raises(ValueError, match="token_issue_secret"): + await channel.start() + + +@pytest.mark.asyncio +async def test_start_refuses_public_bind_without_token_gate( + bus: MagicMock, +) -> None: + """Non-loopback bind with no token gate at all is the same trap, just + expressed via ``websocket_requires_token=False`` instead of the issue + path. Refuse to start.""" + channel = _ch( + bus, + host="0.0.0.0", + port=29914, + websocketRequiresToken=False, + token="", + ) + with pytest.raises(ValueError, match="no token gate"): + await channel.start() + + @pytest.mark.asyncio async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) -> None: sm = _seed_session(tmp_path) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index d217c5f03d4..50830182821 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -968,10 +968,18 @@ async def _connect_mcp(self) -> None: async def close_mcp(self) -> None: return None - def _fake_create_app(agent_loop, model_name: str, request_timeout: float): + def _fake_create_app( + agent_loop, + model_name: str, + request_timeout: float, + auth_token: str = "", + allowed_origins: tuple[str, ...] | list[str] | None = None, + ): seen["agent_loop"] = agent_loop seen["model_name"] = model_name seen["request_timeout"] = request_timeout + seen["auth_token"] = auth_token + seen["allowed_origins"] = tuple(allowed_origins or ()) return _FakeApiApp() def _fake_run_app(api_app, host: str, port: int, print): @@ -1666,6 +1674,76 @@ def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> N assert seen["request_timeout"] == 46.0 +def test_serve_refuses_non_loopback_without_auth_token( + monkeypatch, tmp_path: Path +) -> None: + """Binding the API to a non-loopback host with no bearer token would + expose /v1/chat/completions to the world. The CLI must fail loud.""" + config_file = _write_instance_config(tmp_path) + config = Config() + seen: dict[str, object] = {} + + _patch_serve_runtime(monkeypatch, config, seen) + + result = runner.invoke( + app, + [ + "serve", + "--config", + str(config_file), + "--host", + "0.0.0.0", + ], + ) + + assert result.exit_code != 0 + assert "auth token" in result.output.lower() + # Server must not have started — no aiohttp run_app invocation. + assert "api_app" not in seen + + +def test_serve_passes_auth_token_to_create_app(monkeypatch, tmp_path: Path) -> None: + """When --auth-token is provided, it must reach create_app and the + non-loopback check must pass.""" + config_file = _write_instance_config(tmp_path) + config = Config() + seen: dict[str, object] = {} + + _patch_serve_runtime(monkeypatch, config, seen) + + result = runner.invoke( + app, + [ + "serve", + "--config", + str(config_file), + "--host", + "0.0.0.0", + "--auth-token", + "supersecret", + ], + ) + + assert result.exit_code == 0 + assert seen["auth_token"] == "supersecret" + + +def test_serve_reads_auth_token_from_config(monkeypatch, tmp_path: Path) -> None: + """--auth-token omitted should fall back to api.authToken in config.""" + config_file = _write_instance_config(tmp_path) + config = Config() + config.api.host = "0.0.0.0" + config.api.auth_token = "from-config" + seen: dict[str, object] = {} + + _patch_serve_runtime(monkeypatch, config, seen) + + result = runner.invoke(app, ["serve", "--config", str(config_file)]) + + assert result.exit_code == 0 + assert seen["auth_token"] == "from-config" + + def test_channels_login_requires_channel_name() -> None: result = runner.invoke(app, ["channels", "login"]) diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py new file mode 100644 index 00000000000..376457b5699 --- /dev/null +++ b/tests/test_api_auth.py @@ -0,0 +1,212 @@ +"""Tests for the bearer-token middleware on /v1/* in nanobot.api.server.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio + +from nanobot.api.server import create_app + +try: + from aiohttp.test_utils import TestClient, TestServer + + HAS_AIOHTTP = True +except ImportError: + HAS_AIOHTTP = False + +pytest_plugins = ("pytest_asyncio",) + + +def _agent_returning(text: str) -> MagicMock: + agent = MagicMock() + agent.process_direct = AsyncMock(return_value=text) + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + return agent + + +@pytest_asyncio.fixture +async def aiohttp_client(): + clients: list[TestClient] = [] + + async def _make_client(app): + client = TestClient(TestServer(app)) + await client.start_server() + clients.append(client) + return client + + try: + yield _make_client + finally: + for client in clients: + await client.close() + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_no_auth_token_keeps_v1_open(aiohttp_client) -> None: + """When auth_token is empty the middleware is a no-op (preserves the + existing local-only deployment shape).""" + app = create_app(_agent_returning("ok"), model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 200 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_v1_requires_bearer_when_auth_token_set(aiohttp_client) -> None: + app = create_app(_agent_returning("ok"), model_name="m", auth_token="s3cret") + client = await aiohttp_client(app) + + deny = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}]}, + ) + assert deny.status == 401 + + bad = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}]}, + headers={"Authorization": "Bearer wrong"}, + ) + assert bad.status == 401 + + ok = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}]}, + headers={"Authorization": "Bearer s3cret"}, + ) + assert ok.status == 200 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_v1_models_requires_bearer(aiohttp_client) -> None: + app = create_app(_agent_returning("ok"), model_name="m", auth_token="s3cret") + client = await aiohttp_client(app) + + deny = await client.get("/v1/models") + assert deny.status == 401 + + ok = await client.get( + "/v1/models", headers={"Authorization": "Bearer s3cret"} + ) + assert ok.status == 200 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_health_is_exempt_from_auth(aiohttp_client) -> None: + """/health stays open for liveness probes even when auth is configured.""" + app = create_app(_agent_returning("ok"), model_name="m", auth_token="s3cret") + client = await aiohttp_client(app) + + resp = await client.get("/health") + assert resp.status == 200 + + +# --------------------------------------------------------------------------- +# Browser-CSRF guard (closes the text/plain + Origin gap on default loopback) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_v1_rejects_browser_simple_text_plain(aiohttp_client) -> None: + """A browser-simple `text/plain` POST must not reach the agent, even with + auth disabled. This is the reviewer's reported CSRF vector.""" + agent = _agent_returning("ok") + app = create_app(agent, model_name="m", auth_token="") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + data='{"messages":[{"role":"user","content":"pwned"}]}', + headers={"Content-Type": "text/plain;charset=UTF-8"}, + ) + assert resp.status == 415 + agent.process_direct.assert_not_awaited() + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_v1_rejects_unallowlisted_origin(aiohttp_client) -> None: + """Any browser Origin not in the allowlist is rejected, even with valid + Content-Type and no auth required.""" + agent = _agent_returning("ok") + app = create_app(agent, model_name="m", auth_token="") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}]}, + headers={"Origin": "https://evil.example"}, + ) + assert resp.status == 403 + agent.process_direct.assert_not_awaited() + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_v1_accepts_allowlisted_origin(aiohttp_client) -> None: + agent = _agent_returning("ok") + app = create_app( + agent, + model_name="m", + auth_token="", + allowed_origins=("https://app.example",), + ) + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}]}, + headers={"Origin": "https://app.example"}, + ) + assert resp.status == 200 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_v1_accepts_non_browser_clients(aiohttp_client) -> None: + """curl / openai-python / LiteLLM don't send Origin and use + application/json — they must continue to work with no auth.""" + agent = _agent_returning("ok") + app = create_app(agent, model_name="m", auth_token="") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 200 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_health_skips_csrf_guard(aiohttp_client) -> None: + """/health stays unauthenticated and unfiltered — liveness probes don't + set Origin or interesting Content-Type.""" + app = create_app(_agent_returning("ok"), model_name="m", auth_token="") + client = await aiohttp_client(app) + + resp = await client.get("/health", headers={"Origin": "https://evil.example"}) + assert resp.status == 200 + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_v1_models_get_skips_csrf_guard(aiohttp_client) -> None: + """GETs have no side effects, so the CSRF guard doesn't apply to them.""" + app = create_app(_agent_returning("ok"), model_name="m", auth_token="") + client = await aiohttp_client(app) + + resp = await client.get("/v1/models", headers={"Origin": "https://evil.example"}) + assert resp.status == 200