Skip to content

fix(security): harden public-deploy footguns + browser-CSRF on /v1/*#3492

Open
mohamed-elkholy95 wants to merge 2 commits intoHKUDS:nightlyfrom
mohamed-elkholy95:fix/security-public-deploy-hardening
Open

fix(security): harden public-deploy footguns + browser-CSRF on /v1/*#3492
mohamed-elkholy95 wants to merge 2 commits intoHKUDS:nightlyfrom
mohamed-elkholy95:fix/security-public-deploy-hardening

Conversation

@mohamed-elkholy95
Copy link
Copy Markdown
Contributor

@mohamed-elkholy95 mohamed-elkholy95 commented Apr 28, 2026

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/bootstrap mints a token that authorizes the WebSocket handshake plus the /api/sessions read/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 serve has no auth at all on /v1/*. Pointing --host 0.0.0.0 (or fronting it with a proxy) exposes /v1/chat/completions to the world. The CLI only printed a console.print warning.
  • WebSocket token-issue route issues tokens to anyone when tokenIssuePath is set without tokenIssueSecret. Logged a warning and proceeded.
  • Default serve on 127.0.0.1 is reachable from any browser tab. A no-cors fetch with Content-Type: text/plain;charset=UTF-8 is browser-simple (no preflight) and was reaching agent.process_direct(...) with attacker-controlled session_id/message.

Fix

WebUI bootstrap (nanobot/channels/websocket.py)

  • Refuse when the channel's bind host is not a loopback address. A non-loopback bind is an explicit public posture; the implicit token mint is unsafe there.
  • Reject requests carrying standard proxy-hop headers (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.
  • New webuiBootstrapDisabled: bool = false config so operators behind a proxy that strips/omits the 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 three literals.

WebSocket channel startup

Refuse to start when bind is non-loopback and either:

  • tokenIssuePath is set without tokenIssueSecret, or
  • there is no token gate at all (websocketRequiresToken: false with no static token).

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)

  • New api.authToken config + --auth-token CLI flag.
  • Aiohttp middleware enforces Authorization: Bearer <token> on /v1/* when configured; /health stays open for liveness probes.
  • CLI refuses to start when bind is non-loopback and no auth token is set.

Browser-CSRF protection on /v1/* (loopback included)

Even when authToken is unset, the browser-localhost vector is closed. CSRF middleware on POST /v1/*:

  • Rejects any Origin not in api.allowedOrigins (403). Empty allowlist means "no browsers"; non-browser clients (curl, openai-python, LiteLLM) don't send Origin and pass.
  • Requires Content-Type application/json or multipart/form-data (415). Closes the text/plain simple-request path that bypasses preflight.
  • /health and GET /v1/models stay exempt (no side effects).
  • New --allowed-origin flag and api.allowedOrigins config 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

  • All defaults preserve current local-only behavior. No auth_token = no Bearer middleware = no behavior change for non-browser clients.
  • webuiBootstrapDisabled defaults to false.
  • allowedOrigins defaults to []. Browser clients with an Origin header now require allowlisting; curl/openai-python/LiteLLM are unaffected.
  • The startup refusals only fire on non-loopback binds, so existing local installs are unaffected.

Tests

$ 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 \
    tests/test_api_auth.py tests/cli/test_commands.py -q

Coverage:

  • test_bootstrap_rejects_x_forwarded_for / test_bootstrap_rejects_forwarded_header — bootstrap → 403 with proxy-hop headers
  • test_start_refuses_public_token_issue_without_secret / test_start_refuses_public_bind_without_token_gate — channel raises at start
  • test_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 middleware
  • test_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 middleware
  • test_serve_refuses_non_loopback_without_auth_token / test_serve_passes_auth_token_to_create_app / test_serve_reads_auth_token_from_config — CLI wiring

End-to-end smoke (run locally):

Scenario Expected Actual
loopback bind, plain GET /webui/bootstrap 200 + token
loopback bind, X-Forwarded-For header 403
loopback bind, Forwarded: header 403
non-loopback bind, bootstrap 403
non-loopback + no secret + no static token refuses to start (ValueError)
webuiBootstrapDisabled: true 403
127.0.0.5 (non-.1 loopback) recognized true
loopback POST /v1/chat/completions with Origin: https://evil.example, text/plain 403
loopback POST /v1/chat/completions from curl (no Origin, application/json) 200
--allowed-origin https://evil.example then same browser request 200

Docs

  • 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 covering allowedOrigins.
  • docs/websocket.md: documents webuiBootstrapDisabled and 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 targets nightly. No behavior change for default local installs except that browser clients with an Origin header now need allowlisting.

Commits

  • fix(security): harden public-deploy footguns on WebUI bootstrap and API serve
  • fix(security): block browser-CSRF on /v1/* even on loopback

@Hinotoi-agent
Copy link
Copy Markdown
Contributor

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, api.authToken defaults to empty, and the middleware is intentionally a no-op when it is not configured. That preserves local compatibility, but it means a default nanobot serve on 127.0.0.1:8900 can still be driven by an arbitrary website. CORS prevents the site from reading the response, but it does not prevent a side-effecting request from being sent.

I checked the PR branch with a focused regression test against create_app(..., auth_token=""). A request with:

Origin: https://evil.example
Content-Type: text/plain;charset=UTF-8

and a JSON body to /v1/chat/completions still returned 200 and reached agent.process_direct(...) with the attacker-controlled session_id/message. The important detail is that text/plain is browser-simple, so a no-cors fetch can send it without a preflight.

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?

  1. Require or auto-generate a bearer token for nanobot serve even on loopback, rather than leaving /v1/* unauthenticated by default.
  2. Add browser-request hardening for /v1/chat/completions:
    • reject browser-simple JSON such as text/plain; only accept application/json and multipart/form-data
    • reject requests carrying an Origin header unless explicitly allowlisted

That would keep /health unauthenticated for probes, while preventing arbitrary websites from causing local agent/API side effects through the user's browser.

…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.
@mohamed-elkholy95 mohamed-elkholy95 force-pushed the fix/security-public-deploy-hardening branch from 45d6ce9 to 0fa2e0e Compare May 3, 2026 18:15
@mohamed-elkholy95 mohamed-elkholy95 changed the title fix(security): harden public-deploy footguns on WebUI bootstrap and API serve fix(security): harden public-deploy footguns + browser-CSRF on /v1/* May 3, 2026
@mohamed-elkholy95
Copy link
Copy Markdown
Contributor Author

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 serve exposes the agent more than the operator expects"), the fix surface is the same files (api/server.py, cli/commands.py, config/schema.py), and one review trumps two for a small contributor.

Implemented in 0fa2e0ef fix(security): block browser-CSRF on /v1/ even on loopback*. CSRF middleware on POST /v1/*:

  • Rejects any Origin not in api.allowedOrigins (403). Empty allowlist = no browsers; curl/openai-python/LiteLLM don't send Origin and pass through unchanged.
  • Requires Content-Type of application/json or multipart/form-data (415). Closes the text/plain simple-request path.
  • /health and GET /v1/models exempt.
  • New --allowed-origin flag + api.allowedOrigins config for explicit cross-origin allowlisting.

Your PoC (Origin: https://evil.example + text/plain + JSON body) is reproduced as test_v1_blocks_text_plain_csrf_via_origin and now returns 403 without reaching agent.process_direct(...). PR title and body updated.

I considered the alternative of auto-generating a loopback token by default, but decided against it: it would break curl http://127.0.0.1:8900/v1/chat/completions for every existing local user, which is a UX regression heavier than the threat warrants for the default profile. The CSRF-by-Origin path keeps non-browser clients zero-config and only inconveniences browsers, which is exactly the asymmetry we want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants