fix(security): harden public-deploy footguns + browser-CSRF on /v1/*#3492
fix(security): harden public-deploy footguns + browser-CSRF on /v1/*#3492mohamed-elkholy95 wants to merge 2 commits intoHKUDS:nightlyfrom
Conversation
|
Thanks for putting this hardening PR together — the non-loopback/public-deploy guard and bearer middleware look like a solid improvement. One residual gap I noticed: this still seems to leave the browser-to-localhost case open under the preserved default local configuration. In this PR, I checked the PR branch with a focused regression test against Origin: https://evil.example
Content-Type: text/plain;charset=UTF-8and a JSON body to So I think this PR fully addresses the public/non-loopback footgun, but only partially addresses the local control-plane boundary. To close the browser-localhost variant, would you be open to adding one or both of these defenses?
That would keep |
c83940a to
bd11392
Compare
…PI serve
The WebUI bootstrap, the OpenAI-compatible API server, and the WebSocket
token-issue route all had configurations that silently turn dangerous
when the user fronts nanobot with a public tunnel or reverse proxy.
WebUI bootstrap (`/webui/bootstrap`):
- Refuse when the channel's bind host is not a loopback address. The
bootstrap was always meant for same-machine use; a non-loopback bind
is an explicit public posture and the implicit token mint is unsafe.
- Reject requests that carry standard proxy-hop headers
(`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`,
`X-Real-IP`, `Forwarded`) even when the TCP peer is loopback — this
catches the common "tunnel-on-the-same-host" shape (cloudflared,
ngrok, nginx) where the peer is 127.0.0.1 but the real client is
remote.
- Add `webuiBootstrapDisabled` config so operators behind a custom
proxy that strips/omits headers can hard-disable the route.
- Recognize the full IPv4 `127.0.0.0/8` loopback range plus IPv6 `::1`
via `ipaddress.is_loopback`, not just the three common literals.
WebSocket channel startup:
- Refuse to start when host is non-loopback and `tokenIssuePath` is
set without `tokenIssueSecret` (would let any caller mint tokens).
- Refuse to start when host is non-loopback and there's no token gate
at all (`websocketRequiresToken=false` with no static `token`).
OpenAI-compatible API (`nanobot serve`):
- Add `api.authToken` config and `--auth-token` CLI flag.
- Add an aiohttp middleware that requires
`Authorization: Bearer <token>` on `/v1/*` when configured;
`/health` stays open for liveness probes.
- Refuse to start when the bind is non-loopback and no token is set
(was previously a console warning).
Tests cover all four guards and the bearer middleware. The previously
named verification command still passes:
$ uv run pytest tests/channels/test_websocket_http_routes.py \
tests/channels/test_websocket_channel.py::test_websocket_requires_token_without_issue_path \
tests/channels/test_websocket_channel.py::test_http_route_issues_token_then_websocket_requires_it \
-q
77 passed (incl. 5 new tests)
Docs: `docs/deployment.md` gains a "Exposing nanobot publicly" section
covering the recommendation; `docs/websocket.md` documents the new
`webuiBootstrapDisabled` field and the refuse-to-start guards.
Default `nanobot serve` on 127.0.0.1 with no auth_token still let any website POST `Content-Type: text/plain` to /v1/chat/completions via a no-cors fetch — browser-simple, no preflight — reaching agent.process_direct with an attacker-controlled session_id and message. Add a CSRF middleware on POST /v1/*: - reject any `Origin` not in `api.allowedOrigins` (403) - require Content-Type `application/json` or `multipart/form-data` (415) curl/openai-python/LiteLLM don't send `Origin` and use `application/json`, so non-browser clients keep working on loopback with no auth. /health and GET /v1/models stay exempt — no side effects. Adds `--allowed-origin` flag and `api.allowedOrigins` config for explicit browser allowlisting (e.g. WebUI cross-origin deployments). Regression test reproduces the reviewer's PoC and confirms it now returns 403 without reaching the agent.
45d6ce9 to
0fa2e0e
Compare
|
Thanks — the browser-localhost vector is real and your PoC reproduces cleanly. After thinking about scope I decided to fold the fix into this PR rather than split: the threat model is adjacent (both are "default Implemented in
Your PoC ( I considered the alternative of auto-generating a loopback token by default, but decided against it: it would break |
Problem
When operators front nanobot with a public tunnel or reverse proxy (cloudflared, ngrok, nginx), three configurations silently turn dangerous; a fourth even on default loopback lets any website drive the local agent. nanobot is built around a single-user, single-machine deployment model — these surfaces were meant for same-host use, but nothing in the code stopped them from being silently exposed.
/webui/bootstrapmints a token that authorizes the WebSocket handshake plus the/api/sessionsread/delete REST surface (the delete route really unlinks the session JSONL). The localhost gate trusts only the TCP peer, so a tunnel running on the same host (peer = 127.0.0.1) defeats it.nanobot servehas no auth at all on/v1/*. Pointing--host 0.0.0.0(or fronting it with a proxy) exposes/v1/chat/completionsto the world. The CLI only printed aconsole.printwarning.tokenIssuePathis set withouttokenIssueSecret. Logged a warning and proceeded.serveon 127.0.0.1 is reachable from any browser tab. Ano-corsfetch withContent-Type: text/plain;charset=UTF-8is browser-simple (no preflight) and was reachingagent.process_direct(...)with attacker-controlledsession_id/message.Fix
WebUI bootstrap (
nanobot/channels/websocket.py)X-Forwarded-For,X-Forwarded-Host,X-Forwarded-Proto,X-Real-IP,Forwarded) even when the TCP peer is loopback — catches the common tunnel-on-same-host shape.webuiBootstrapDisabled: bool = falseconfig so operators behind a proxy that strips/omits the headers can hard-disable the route.127.0.0.0/8loopback range plus IPv6::1viaipaddress.is_loopback, not just three literals.WebSocket channel startup
Refuse to start when bind is non-loopback and either:
tokenIssuePathis set withouttokenIssueSecret, orwebsocketRequiresToken: falsewith no statictoken).Heuristics are easy to defeat with a custom proxy. A hard error at startup is harder to ignore than a console warning at request time.
OpenAI-compatible API auth (
nanobot/api/server.py,nanobot/cli/commands.py)api.authTokenconfig +--auth-tokenCLI flag.Authorization: Bearer <token>on/v1/*when configured;/healthstays open for liveness probes.Browser-CSRF protection on
/v1/*(loopback included)Even when
authTokenis unset, the browser-localhost vector is closed. CSRF middleware onPOST /v1/*:Originnot inapi.allowedOrigins(403). Empty allowlist means "no browsers"; non-browser clients (curl, openai-python, LiteLLM) don't sendOriginand pass.application/jsonormultipart/form-data(415). Closes thetext/plainsimple-request path that bypasses preflight./healthandGET /v1/modelsstay exempt (no side effects).--allowed-originflag andapi.allowedOriginsconfig for explicit cross-origin allowlisting (e.g. WebUI deployments).This addresses @Hinotoi-agent's review feedback (browser-localhost variant); the reviewer's PoC is reproduced as a regression test (
test_v1_blocks_text_plain_csrf_via_origin) and now returns 403 without reaching the agent.Backwards compatibility
auth_token= no Bearer middleware = no behavior change for non-browser clients.webuiBootstrapDisableddefaults tofalse.allowedOriginsdefaults to[]. Browser clients with anOriginheader now require allowlisting; curl/openai-python/LiteLLM are unaffected.Tests
Coverage:
test_bootstrap_rejects_x_forwarded_for/test_bootstrap_rejects_forwarded_header— bootstrap → 403 with proxy-hop headerstest_start_refuses_public_token_issue_without_secret/test_start_refuses_public_bind_without_token_gate— channel raises at starttest_v1_requires_bearer_when_auth_token_set/test_v1_models_requires_bearer/test_health_is_exempt_from_auth/test_no_auth_token_keeps_v1_open— Bearer middlewaretest_v1_blocks_text_plain_csrf_via_origin/test_v1_allows_origin_in_allowlist/test_v1_allows_no_origin_request/test_v1_models_skips_csrf— browser-CSRF middlewaretest_serve_refuses_non_loopback_without_auth_token/test_serve_passes_auth_token_to_create_app/test_serve_reads_auth_token_from_config— CLI wiringEnd-to-end smoke (run locally):
GET /webui/bootstrapX-Forwarded-ForheaderForwarded:headerValueError)webuiBootstrapDisabled: true127.0.0.5(non-.1loopback) recognizedPOST /v1/chat/completionswithOrigin: https://evil.example,text/plainPOST /v1/chat/completionsfrom curl (no Origin, application/json)--allowed-origin https://evil.examplethen same browser requestDocs
docs/deployment.md: new "Exposing nanobot publicly" section with the explicit recommendation, the WS-only public profile, and the rationale for fail-loud over heuristics; new "Browser-localhost CSRF" subsection coveringallowedOrigins.docs/websocket.md: documentswebuiBootstrapDisabledand the refuse-to-start guards under Authentication.Targeting
Per
CONTRIBUTING.md: this PR adds new config fields (api.authToken,api.allowedOrigins,webuiBootstrapDisabled) and changes startup behavior for non-loopback binds, so it targetsnightly. No behavior change for default local installs except that browser clients with anOriginheader now need allowlisting.Commits
fix(security): harden public-deploy footguns on WebUI bootstrap and API servefix(security): block browser-CSRF on /v1/* even on loopback