From 207813d3b5654afd28b33da805806b142da89c30 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 9 Jul 2026 00:44:26 +0800 Subject: [PATCH] fix(webui): tighten localhost bootstrap check --- docs/websocket.md | 7 ++--- docs/webui.md | 6 ++--- nanobot/webui/ws_http.py | 9 ++++--- tests/channels/test_websocket_http_routes.py | 28 +++++++++++++++----- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/docs/websocket.md b/docs/websocket.md index e778ff4f..df9104cc 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). `/webui/bootstrap` still issues WebUI REST API tokens for localhost requests; remote bootstrap requires `tokenIssueSecret` or `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 same-machine localhost browser requests; remote or forwarded bootstrap requires `tokenIssueSecret` or `token`. | | `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). | ### Access Control @@ -267,8 +267,9 @@ 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 to localhost requests, or -after the request proves knowledge of `tokenIssueSecret` or the static `token`. +It returns a separate `api_token` for REST routes to same-machine localhost +browser 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 fee3017a..663323ed 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. Localhost WebUI access can run without a browser -password. Set `tokenIssueSecret` when you intentionally expose the WebUI beyond -localhost or want a browser password: +Manual config still works. Same-machine 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 22b744a7..9cec235f 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -45,6 +45,9 @@ from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import ( http_response as _http_response, ) +from nanobot.webui.http_utils import ( + is_local_browser_request as _is_local_browser_request, +) from nanobot.webui.http_utils import ( is_localhost as _is_localhost, ) @@ -306,14 +309,14 @@ class GatewayHTTPHandler: def _handle_bootstrap(self, connection: Any, request: Any) -> Response: secret = self.config.token_issue_secret.strip() or self.config.token.strip() - is_local = _is_localhost(connection) + is_local_browser = _is_local_browser_request(connection, request.headers) if secret: if not _issue_route_secret_matches(request.headers, secret): return _http_error(401, "Unauthorized") - elif not is_local: + elif not is_local_browser: return _http_error(403, "bootstrap is localhost-only") - api_token_allowed = bool(secret) or is_local + api_token_allowed = bool(secret) or is_local_browser 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 398b6ee6..a20bb657 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -1971,6 +1971,7 @@ class _FakeReq: _REMOTE = _FakeConn(("192.168.1.5", 12345)) _LOCAL = _FakeConn(("127.0.0.1", 12345)) _NO_HEADERS = _FakeReq() +_LOCAL_BROWSER_REQ = _FakeReq({"Host": "127.0.0.1:8765"}) def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None: @@ -2058,10 +2059,16 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None: def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None: - channel = _ch(bus, host="127.0.0.1", port=29931) + channel = _ch(bus, host="127.0.0.1", port=29931, tokenIssueSecret="s3cret") resp = channel.gateway.http._handle_bootstrap( _LOCAL, - _FakeReq({"Host": "nanobot.example", "X-Forwarded-Proto": "https"}), + _FakeReq( + { + "Authorization": "Bearer s3cret", + "Host": "nanobot.example", + "X-Forwarded-Proto": "https", + } + ), ) assert resp.status_code == 200 body = json.loads(resp.body) @@ -2074,9 +2081,18 @@ def test_bootstrap_without_auth_rejects_remote_requests(bus: MagicMock) -> None: assert resp.status_code == 403 +def test_bootstrap_without_auth_rejects_reverse_proxy_remote_headers(bus: MagicMock) -> None: + channel = _ch(bus, host="127.0.0.1") + resp = channel.gateway.http._handle_bootstrap( + _LOCAL, + _FakeReq({"Host": "nanobot.example", "X-Forwarded-For": "203.0.113.42"}), + ) + 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) + resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ) assert resp.status_code == 200 body = json.loads(resp.body) assert body["token"].startswith("nbwt_") @@ -2114,7 +2130,7 @@ def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytes lambda: "from-disk", ) channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ") - resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS) + resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ) assert resp.status_code == 200 body = json.loads(resp.body) assert body["model_name"] == "live/model" @@ -2126,7 +2142,7 @@ def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeyp lambda: "from-disk", ) channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ") - resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS) + resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ) assert resp.status_code == 200 body = json.loads(resp.body) assert body["model_name"] == "from-disk" @@ -2142,7 +2158,7 @@ def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: p raise RuntimeError("resolver failed") channel = _ch(bus, host="127.0.0.1", runtime_model_name=boom) - resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS) + resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ) assert resp.status_code == 200 body = json.loads(resp.body) assert body["model_name"] == "from-disk"