From e0c2d28f90b7784c39e6515881d3a22d5a4eb716 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 9 Jul 2026 00:27:14 +0800 Subject: [PATCH] fix(webui): issue localhost bootstrap api tokens --- docs/websocket.md | 6 +++--- docs/webui.md | 6 +++--- nanobot/webui/ws_http.py | 5 +++-- tests/channels/test_websocket_http_routes.py | 15 +++++++++++++-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/websocket.md b/docs/websocket.md index 0d708da9..e778ff4f 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -221,7 +221,7 @@ All fields go under `channels.websocket` in `config.json`. | `token` | string | `""` | Static shared secret. When set, clients must provide `?token=` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. | | `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). | | `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 WebSocket connection tokens from `tokenIssuePath` (logged as a warning), and `/webui/bootstrap` will not return a WebUI REST API token. | +| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` still issues WebUI REST API tokens for localhost requests; remote bootstrap requires `tokenIssueSecret` or `token`. | | `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). | ### Access Control @@ -267,8 +267,8 @@ For production deployments where `websocketRequiresToken: true`, use short-lived 4. The token is consumed (single use) and cannot be reused. The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token. -It returns a separate `api_token` for REST routes only after the request proves -knowledge of `tokenIssueSecret` or the static `token`. +It returns a separate `api_token` for REST routes to localhost requests, or +after the request proves knowledge of `tokenIssueSecret` or the static `token`. ### Example setup diff --git a/docs/webui.md b/docs/webui.md index deb8090d..fee3017a 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -33,9 +33,9 @@ nanobot webui --background Manage the background gateway with `nanobot gateway status`, `nanobot gateway logs`, `nanobot gateway restart`, and `nanobot gateway stop`. -Manual config still works. Set `tokenIssueSecret` for full WebUI access; it is -required before `/webui/bootstrap` returns a REST API token for session, settings, -Apps, Skills, and automation routes: +Manual config still works. Localhost WebUI access can run without a browser +password. Set `tokenIssueSecret` when you intentionally expose the WebUI beyond +localhost or want a browser password: ```json { diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 1eeb192c..22b744a7 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -306,13 +306,14 @@ class GatewayHTTPHandler: def _handle_bootstrap(self, connection: Any, request: Any) -> Response: secret = self.config.token_issue_secret.strip() or self.config.token.strip() - api_token_allowed = bool(secret) + is_local = _is_localhost(connection) if secret: if not _issue_route_secret_matches(request.headers, secret): return _http_error(401, "Unauthorized") - elif not _is_localhost(connection): + elif not is_local: return _http_error(403, "bootstrap is localhost-only") + api_token_allowed = bool(secret) or is_local if not self.tokens.can_issue(include_api_token=api_token_allowed): return _http_response( json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"), diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index b019f8de..398b6ee6 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -203,7 +203,8 @@ async def test_bootstrap_returns_token_for_localhost( assert resp.status_code == 200 body = resp.json() assert body["token"].startswith("nbwt_") - assert "api_token" not in body + assert body["api_token"].startswith("nbwt_") + assert body["api_token"] != body["token"] assert body["ws_path"] == "/" assert body["ws_url"] == "ws://127.0.0.1:29901/" assert body["expires_in"] > 0 @@ -2067,16 +2068,26 @@ def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None: assert body["ws_url"] == "wss://nanobot.example/" +def test_bootstrap_without_auth_rejects_remote_requests(bus: MagicMock) -> None: + channel = _ch(bus, host="127.0.0.1") + resp = channel.gateway.http._handle_bootstrap(_REMOTE, _NO_HEADERS) + assert resp.status_code == 403 + + def test_localhost_without_auth_is_valid(bus: MagicMock) -> None: channel = _ch(bus, host="127.0.0.1") resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS) assert resp.status_code == 200 body = json.loads(resp.body) assert body["token"].startswith("nbwt_") - assert "api_token" not in body + assert body["api_token"].startswith("nbwt_") + assert body["api_token"] != body["token"] assert not channel.gateway.tokens.check_api_token( _FakeReq({"Authorization": f"Bearer {body['token']}"}) ) + assert channel.gateway.tokens.check_api_token( + _FakeReq({"Authorization": f"Bearer {body['api_token']}"}) + ) def test_authenticated_bootstrap_returns_distinct_api_token(bus: MagicMock) -> None: