fix(webui): require token_issue_secret for non-localhost bootstrap

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.
This commit is contained in:
chengyongru 2026-05-06 23:10:24 +08:00 committed by Xubin Ren
parent bad584cb0e
commit 034bea1a44
3 changed files with 63 additions and 25 deletions

View File

@ -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()

View File

@ -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

View File

@ -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://<your-ip>: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://<your-ip>:8765` on the other device. The bootstrap endpoint requires the secret via the `Authorization: Bearer <secret>` 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