From 4efd904ccccabc49504f3582b3b992892b5110a4 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 6 May 2026 23:15:18 +0800 Subject: [PATCH] fix(webui): require token_issue_secret for LAN access with frontend auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When host is set to 0.0.0.0, the gateway now enforces that either token or token_issue_secret must be configured — it refuses to start otherwise. Bootstrap endpoint behavior: - token_issue_secret configured: always validate regardless of source IP (handles reverse-proxy scenarios where all connections appear as localhost) - No secret: only localhost can bootstrap (local dev mode) The frontend shows an authentication form when bootstrap returns 401/403, persists the secret in localStorage, and retries automatically on reload. --- nanobot/channels/websocket.py | 19 +- tests/channels/test_websocket_http_routes.py | 49 ++++- webui/README.md | 6 +- webui/src/App.tsx | 167 ++++++++++++++---- .../src/components/settings/SettingsView.tsx | 20 +++ webui/src/i18n/locales/en/common.json | 12 ++ webui/src/lib/bootstrap.ts | 38 +++- webui/src/tests/app-layout.test.tsx | 3 + 8 files changed, 265 insertions(+), 49 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 58bd1515f..4838fcece 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -128,6 +128,17 @@ class WebSocketConfig(Base): raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)") return self + @model_validator(mode="after") + def wildcard_host_requires_auth(self) -> Self: + if self.host not in ("0.0.0.0", "::"): + return self + if self.token.strip() or self.token_issue_secret.strip(): + return self + raise ValueError( + "host is 0.0.0.0 (all interfaces) but neither token nor " + "token_issue_secret is set — set one to prevent unauthenticated access" + ) + def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response: body = json.dumps(data, ensure_ascii=False).encode("utf-8") @@ -607,10 +618,10 @@ class WebSocketChannel(BaseChannel): self._api_tokens.pop(token_key, None) 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() + # When a secret is configured (token_issue_secret or static token), + # validate it regardless of source IP. This secures deployments + # behind a reverse proxy where all connections appear as localhost. + secret = self.config.token_issue_secret.strip() or self.config.token.strip() if secret: if not _issue_route_secret_matches(request.headers, secret): return _http_error(401, "Unauthorized") diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 2a87ef372..40ba19288 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -405,13 +405,52 @@ _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_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None: + import pytest + from pydantic_core import ValidationError + + with pytest.raises(ValidationError, match="token"): + _ch(bus, host="0.0.0.0") -def test_bootstrap_allows_localhost_without_secret(bus: MagicMock) -> None: +def test_wildcard_host_with_token_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", token="my-token") + assert channel.config.host == "0.0.0.0" + + +def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + assert channel.config.host == "0.0.0.0" + + +def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None: + import pytest + from pydantic_core import ValidationError + + with pytest.raises(ValidationError, match="token"): + _ch(bus, host="::") + + +def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="::", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"}) + ) + assert resp.status_code == 200 + + +def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None: + """When only token (not token_issue_secret) is set, bootstrap accepts it.""" + channel = _ch(bus, host="0.0.0.0", token="static-tok") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"Authorization": "Bearer static-tok"}) + ) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["token"].startswith("nbwt_") + + +def test_localhost_without_auth_is_valid(bus: MagicMock) -> None: channel = _ch(bus, host="127.0.0.1") resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) assert resp.status_code == 200 diff --git a/webui/README.md b/webui/README.md index ae561024e..b99874ba0 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"` and configure `token_issue_secret` in `~/.nanobot/config.json`: +To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`: ```json { @@ -89,9 +89,9 @@ To use the webui from another device on the same network, set `host` to `"0.0.0. } ``` -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. +The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set. -> **Note:** This exposes the gateway to all interfaces. Always set `tokenIssueSecret` on non-local networks. +Then open `http://:8765` on the other device. The webui will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once. ## Build for packaged runtime diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 0fbb3f54f..9eca02688 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -9,14 +9,23 @@ import { preloadMarkdownText } from "@/components/MarkdownText"; import { useSessions } from "@/hooks/useSessions"; import { useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; -import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; +import { + clearSavedSecret, + deriveWsUrl, + fetchBootstrap, + loadSavedSecret, + saveSecret, +} from "@/lib/bootstrap"; import { NanobotClient } from "@/lib/nanobot-client"; import { ClientProvider } from "@/providers/ClientProvider"; import type { ChatSummary } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; type BootState = | { status: "loading" } | { status: "error"; message: string } + | { status: "auth"; failed?: boolean } | { status: "ready"; client: NanobotClient; @@ -28,6 +37,60 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const SIDEBAR_WIDTH = 272; type ShellView = "chat" | "settings"; +function AuthForm({ + failed, + onSecret, +}: { + failed: boolean; + onSecret: (secret: string) => void; +}) { + const { t } = useTranslation(); + const [value, setValue] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const secret = value.trim(); + if (!secret) return; + setSubmitting(true); + onSecret(secret); + }; + + return ( +
+
+
+

{t("app.auth.title")}

+

{t("app.auth.hint")}

+
+ {failed && ( +

+ {t("app.auth.invalid")} +

+ )} + setValue(e.target.value)} + disabled={submitting} + autoFocus + /> + +
+
+ ); +} + function readSidebarOpen(): boolean { if (typeof window === "undefined") return true; try { @@ -43,40 +106,55 @@ export default function App() { const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); + const bootstrapWithSecret = useCallback( + (secret: string) => { + let cancelled = false; + (async () => { + setState({ status: "loading" }); + try { + const boot = await fetchBootstrap("", secret); + if (cancelled) return; + if (secret) saveSecret(secret); + const url = deriveWsUrl(boot.ws_path, boot.token); + const client = new NanobotClient({ + url, + onReauth: async () => { + try { + const refreshed = await fetchBootstrap("", secret); + return deriveWsUrl(refreshed.ws_path, refreshed.token); + } catch { + return null; + } + }, + }); + client.connect(); + setState({ + status: "ready", + client, + token: boot.token, + modelName: boot.model_name ?? null, + }); + } catch (e) { + if (cancelled) return; + const msg = (e as Error).message; + if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { + setState({ status: "auth", failed: true }); + } else { + setState({ status: "error", message: msg }); + } + } + })(); + return () => { + cancelled = true; + }; + }, + [], + ); + useEffect(() => { - let cancelled = false; - (async () => { - try { - const boot = await fetchBootstrap(); - if (cancelled) return; - const url = deriveWsUrl(boot.ws_path, boot.token); - const client = new NanobotClient({ - url, - onReauth: async () => { - try { - const refreshed = await fetchBootstrap(); - return deriveWsUrl(refreshed.ws_path, refreshed.token); - } catch { - return null; - } - }, - }); - client.connect(); - setState({ - status: "ready", - client, - token: boot.token, - modelName: boot.model_name ?? null, - }); - } catch (e) { - if (cancelled) return; - setState({ status: "error", message: (e as Error).message }); - } - })(); - return () => { - cancelled = true; - }; - }, []); + const saved = loadSavedSecret(); + return bootstrapWithSecret(saved); + }, [bootstrapWithSecret]); useEffect(() => { const warm = () => preloadMarkdownText(); @@ -110,6 +188,14 @@ export default function App() { ); } + if (state.status === "auth") { + return ( + bootstrapWithSecret(s)} + /> + ); + } if (state.status === "error") { return (
@@ -130,18 +216,26 @@ export default function App() { ); }; + const handleLogout = () => { + if (state.status === "ready") { + state.client.close(); + } + clearSavedSecret(); + setState({ status: "auth" }); + }; + return ( - + ); } -function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) { +function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) { const { t, i18n } = useTranslation(); const { theme, toggle } = useTheme(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); @@ -319,6 +413,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | onToggleTheme={toggle} onBackToChat={() => setView("chat")} onModelNameChange={onModelNameChange} + onLogout={onLogout} /> ) : ( void; onBackToChat: () => void; onModelNameChange: (modelName: string | null) => void; + onLogout?: () => void; } export function SettingsView({ onBackToChat, onModelNameChange, + onLogout, }: SettingsViewProps) { const { token } = useClient(); const [settings, setSettings] = useState(null); @@ -115,6 +118,7 @@ export function SettingsView({ dirty={dirty} saving={saving} onSave={save} + onLogout={onLogout} /> ) : null} @@ -129,6 +133,7 @@ function SettingsSection({ dirty, saving, onSave, + onLogout, }: { form: { model: string; @@ -142,7 +147,9 @@ function SettingsSection({ dirty: boolean; saving: boolean; onSave: () => void; + onLogout?: () => void; }) { + const { t } = useTranslation(); return (
@@ -192,6 +199,19 @@ function SettingsSection({
+ + {onLogout && ( +
+

{t("app.account.section")}

+ + + + + +
+ )}
); } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 90e2532c3..0d8221bf8 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -9,6 +9,18 @@ "title": "Couldn't reach nanobot", "gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine." }, + "auth": { + "title": "Authentication required", + "hint": "Enter the secret configured as tokenIssueSecret in your gateway config.", + "placeholder": "Password", + "submit": "Connect", + "invalid": "Invalid password. Try again." + }, + "account": { + "section": "Account", + "logoutHint": "Disconnect this browser from the gateway.", + "logout": "Sign out" + }, "documentTitle": { "base": "nanobot", "chat": "{{title}} · nanobot" diff --git a/webui/src/lib/bootstrap.ts b/webui/src/lib/bootstrap.ts index 66d2b5958..931484a87 100644 --- a/webui/src/lib/bootstrap.ts +++ b/webui/src/lib/bootstrap.ts @@ -1,15 +1,51 @@ import type { BootstrapResponse } from "./types"; +const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret"; + +/** Read a previously saved bootstrap secret from localStorage. */ +export function loadSavedSecret(): string { + if (typeof window === "undefined") return ""; + try { + return window.localStorage.getItem(SECRET_STORAGE_KEY) ?? ""; + } catch { + return ""; + } +} + +/** Persist the bootstrap secret so page reloads don't re-prompt. */ +export function saveSecret(secret: string): void { + try { + window.localStorage.setItem(SECRET_STORAGE_KEY, secret); + } catch { + // ignore storage errors (private mode, etc.) + } +} + +/** Clear the saved bootstrap secret (sign out). */ +export function clearSavedSecret(): void { + try { + window.localStorage.removeItem(SECRET_STORAGE_KEY); + } catch { + // ignore + } +} + /** * Fetch a short-lived token + the WebSocket path from the gateway's - * ``/webui/bootstrap`` endpoint. Localhost-only on the server side. + * ``/webui/bootstrap`` endpoint. */ export async function fetchBootstrap( baseUrl: string = "", + secret: string = "", ): Promise { + const headers: Record = {}; + if (secret) { + headers["X-Nanobot-Auth"] = secret; + } const res = await fetch(`${baseUrl}/webui/bootstrap`, { method: "GET", credentials: "same-origin", + headers, }); if (!res.ok) { throw new Error(`bootstrap failed: HTTP ${res.status}`); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 800fb82aa..25248230e 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -46,6 +46,9 @@ vi.mock("@/lib/bootstrap", () => ({ expires_in: 300, }), deriveWsUrl: vi.fn(() => "ws://test"), + loadSavedSecret: vi.fn(() => ""), + saveSecret: vi.fn(), + clearSavedSecret: vi.fn(), })); vi.mock("@/lib/nanobot-client", () => {