Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`.
- 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.
9 changes: 9 additions & 0 deletions docs/websocket.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 91 additions & 2 deletions nanobot/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,20 +378,109 @@ 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 <auth_token>`` 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.

Args:
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 <auth_token>``. /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)
Expand Down
126 changes: 117 additions & 9 deletions nanobot/channels/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import hashlib
import hmac
import http
import ipaddress
import json
import mimetypes
import re
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading