From 034bea1a445afcd91ff68c7888ab81e235b97791 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 6 May 2026 23:10:24 +0800 Subject: [PATCH] fix(webui): require token_issue_secret for non-localhost bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous LAN-access fix (PR #3656) relaxed the bootstrap localhost check when host was 0.0.0.0, but did not require any authentication — any device on the network could obtain a token without credentials. New behavior: - token_issue_secret configured: always validate, regardless of source IP (handles reverse-proxy scenarios where all connections appear as localhost). - No secret configured: only localhost can bootstrap (local dev mode). This supersedes the host-based check from PR #3656. --- nanobot/channels/websocket.py | 18 ++++-- tests/channels/test_websocket_http_routes.py | 61 +++++++++++++++----- webui/README.md | 9 +-- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 7a1e2a06f..58bd1515f 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -531,9 +531,9 @@ class WebSocketChannel(BaseChannel): if got == issue_expected: return self._handle_token_issue_http(connection, request) - # 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI. + # 2. WebUI bootstrap: 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": @@ -606,10 +606,16 @@ class WebSocketChannel(BaseChannel): if now > expiry: self._api_tokens.pop(token_key, None) - def _handle_webui_bootstrap(self, connection: Any) -> Response: - if self.config.host not in ("0.0.0.0", "::") and not _is_localhost( - connection, - ): + def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response: + # When token_issue_secret is configured, validate it regardless of + # source IP. This secures deployments behind a reverse proxy (e.g. + # nginx) where all connections appear as localhost. + secret = self.config.token_issue_secret.strip() + if secret: + if not _issue_route_secret_matches(request.headers, secret): + return _http_error(401, "Unauthorized") + elif not _is_localhost(connection): + # No secret configured: only allow localhost (local dev mode). 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() diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index e09611956..2a87ef372 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -393,27 +393,58 @@ class _FakeConn: return Response(status=status, body=body.encode()) -def test_bootstrap_rejects_non_localhost_by_default(bus: MagicMock) -> None: - channel = _ch(bus, host="127.0.0.1") - conn = _FakeConn(("192.168.1.5", 12345)) - resp = channel._handle_webui_bootstrap(conn) +class _FakeReq: + """Minimal request stub with configurable headers.""" + + def __init__(self, headers: dict[str, str] | None = None): + self.headers = headers or {} + + +_REMOTE = _FakeConn(("192.168.1.5", 12345)) +_LOCAL = _FakeConn(("127.0.0.1", 12345)) +_NO_HEADERS = _FakeReq() + + +def test_bootstrap_rejects_non_localhost_without_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0") + resp = channel._handle_webui_bootstrap(_REMOTE, _NO_HEADERS) assert resp.status_code == 403 -def test_bootstrap_allows_non_localhost_when_host_is_wildcard(bus: MagicMock) -> None: - channel = _ch(bus, host="0.0.0.0") - conn = _FakeConn(("192.168.1.5", 12345)) - resp = channel._handle_webui_bootstrap(conn) +def test_bootstrap_allows_localhost_without_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="127.0.0.1") + resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) + assert resp.status_code == 200 + + +def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"Authorization": "Bearer wrong"}) + ) + assert resp.status_code == 401 + + +def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"Authorization": "Bearer s3cret"}) + ) assert resp.status_code == 200 body = json.loads(resp.body) assert body["token"].startswith("nbwt_") - assert body["ws_path"] == "/" -def test_bootstrap_allows_non_localhost_when_host_is_ipv6_wildcard( - bus: MagicMock, -) -> None: - channel = _ch(bus, host="::") - conn = _FakeConn(("192.168.1.5", 12345)) - resp = channel._handle_webui_bootstrap(conn) +def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"}) + ) assert resp.status_code == 200 + + +def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None: + """When secret is set, even localhost must provide it (reverse-proxy safety).""" + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) + assert resp.status_code == 401 diff --git a/webui/README.md b/webui/README.md index 056fe85f4..ae561024e 100644 --- a/webui/README.md +++ b/webui/README.md @@ -74,7 +74,7 @@ NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev ### Access from another device (LAN) -To use the webui from another device on the same network, set `host` to `"0.0.0.0"` in `~/.nanobot/config.json`: +To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure `token_issue_secret` in `~/.nanobot/config.json`: ```json { @@ -82,15 +82,16 @@ To use the webui from another device on the same network, set `host` to `"0.0.0. "websocket": { "enabled": true, "host": "0.0.0.0", - "port": 8765 + "port": 8765, + "tokenIssueSecret": "your-secret-here" } } } ``` -Then open `http://:8765` on the other device. When `host` is `"0.0.0.0"`, the bootstrap endpoint accepts requests from any source instead of restricting to localhost. +Then open `http://:8765` on the other device. The bootstrap endpoint requires the secret via the `Authorization: Bearer ` header (or `X-Nanobot-Auth`). Without a configured secret, only localhost connections can bootstrap. -> **Note:** This exposes the gateway to all interfaces. Only use on trusted networks. +> **Note:** This exposes the gateway to all interfaces. Always set `tokenIssueSecret` on non-local networks. ## Build for packaged runtime