diff --git a/CHANGELOG.md b/CHANGELOG.md index daebcd78e1c..121bb67b185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -602,6 +602,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Dashboard/macOS auth handling: switch the macOS “Open Dashboard” flow from query-string token injection to URL fragments, stop persisting Control UI gateway tokens in browser localStorage, and scrub legacy stored tokens on load. Thanks @JNX03 for reporting. - Models/provider config precedence: prefer exact `models.providers.` matches before normalized provider aliases in embedded model resolution, preventing alias/canonical key collisions from applying the wrong provider `api`, `baseUrl`, or headers. (#35934) thanks @RealKai42. - Hooks/auth throttling: reject non-`POST` `/hooks/*` requests before auth-failure accounting so unsupported methods can no longer burn the hook auth lockout budget and block legitimate webhook delivery. Thanks @JNX03 for reporting. - Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting. diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 141b7c43685..7105f60cb80 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -661,18 +661,20 @@ extension GatewayEndpointStore { components.path = "/" } - var queryItems: [URLQueryItem] = [] + var fragmentItems: [URLQueryItem] = [] if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { - queryItems.append(URLQueryItem(name: "token", value: token)) + fragmentItems.append(URLQueryItem(name: "token", value: token)) } - if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines), - !password.isEmpty - { - queryItems.append(URLQueryItem(name: "password", value: password)) + components.queryItems = nil + if fragmentItems.isEmpty { + components.fragment = nil + } else { + var fragment = URLComponents() + fragment.queryItems = fragmentItems + components.fragment = fragment.percentEncodedQuery } - components.queryItems = queryItems.isEmpty ? nil : queryItems guard let url = components.url else { throw NSError(domain: "Dashboard", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Failed to build dashboard URL", diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 3d7796879f6..7aff5f3970b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -216,6 +216,20 @@ import Testing #expect(url.absoluteString == "https://gateway.example:443/remote-ui/") } + @Test func dashboardURLUsesFragmentTokenAndOmitsPassword() throws { + let config: GatewayConnection.Config = try ( + url: #require(URL(string: "ws://127.0.0.1:18789")), + token: "abc123", + password: "sekret") + + let url = try GatewayEndpointStore.dashboardURL( + for: config, + mode: .local, + localBasePath: "/control") + #expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=abc123") + #expect(url.query == nil) + } + @Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1") #expect(url?.port == 18789) diff --git a/docs/help/faq.md b/docs/help/faq.md index 2ae55caf0c3..65a580dd965 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2503,7 +2503,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not Facts (from code): -- The Control UI stores the token in browser localStorage key `openclaw.control.settings.v1`. +- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage. Fix: diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index ff14af8c4cd..bbee9443b83 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -231,13 +231,14 @@ http://localhost:5173/?gatewayUrl=ws://:18789 Optional one-time auth (if needed): ```text -http://localhost:5173/?gatewayUrl=wss://:18789&token= +http://localhost:5173/?gatewayUrl=wss://:18789#token= ``` Notes: - `gatewayUrl` is stored in localStorage after load and removed from the URL. -- `token` is stored in localStorage; `password` is kept in memory only. +- `token` is imported into memory for the current tab and stripped from the URL; it is not stored in localStorage. +- `password` is kept in memory only. - When `gatewayUrl` is set, the UI does not fall back to config or environment credentials. Provide `token` (or `password`) explicitly. Missing explicit credentials is an error. - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 02e084ffdae..64780ef40d6 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -24,7 +24,8 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth` (token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration). Security note: the Control UI is an **admin surface** (chat, config, exec approvals). -Do not expose it publicly. The UI stores the token in `localStorage` after first load. +Do not expose it publicly. The UI keeps dashboard URL tokens in memory for the current tab +and strips them from the URL after load. Prefer localhost, Tailscale Serve, or an SSH tunnel. ## Fast path (recommended) @@ -36,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel. ## Token basics (local vs remote) - **Localhost**: open `http://127.0.0.1:18789/`. -- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect. +- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); `openclaw dashboard` can pass it via URL fragment for one-time bootstrap, but the Control UI does not persist gateway tokens in localStorage. - If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design. This avoids exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments. - If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance. - **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web). diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index fc442389132..56e805cee66 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -351,7 +351,7 @@ export async function finalizeOnboardingWizard( "Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.", `View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`, `Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`, - "Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).", + "Web UI keeps dashboard URL tokens in memory for the current tab and strips them from the URL after load.", `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, "If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).", ].join("\n"), diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 853bc58b6e4..8dae3fc2a13 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -151,6 +151,9 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.search).toBe(""); }); @@ -167,12 +170,18 @@ describe("control UI routing", () => { it("hydrates token from URL params even when settings already set", async () => { localStorage.setItem( "openclaw.control.settings.v1", - JSON.stringify({ token: "existing-token" }), + JSON.stringify({ token: "existing-token", gatewayUrl: "wss://gateway.example/openclaw" }), ); const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({ + gatewayUrl: "wss://gateway.example/openclaw", + }); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.search).toBe(""); }); @@ -182,6 +191,9 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.hash).toBe(""); }); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 18b91c6a898..34563291fe3 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -24,40 +24,147 @@ function createStorageMock(): Storage { }; } +function setTestLocation(params: { protocol: string; host: string; pathname: string }) { + if (typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState({}, "", params.pathname); + return; + } + vi.stubGlobal("location", { + protocol: params.protocol, + host: params.host, + pathname: params.pathname, + } as Location); +} + +function setControlUiBasePath(value: string | undefined) { + if (typeof window === "undefined") { + vi.stubGlobal( + "window", + value == null + ? ({} as Window & typeof globalThis) + : ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as Window & typeof globalThis), + ); + return; + } + if (value == null) { + delete window.__OPENCLAW_CONTROL_UI_BASE_PATH__; + return; + } + Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", { + value, + writable: true, + configurable: true, + }); +} + +function expectedGatewayUrl(basePath: string): string { + const proto = location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${location.host}${basePath}`; +} + describe("loadSettings default gateway URL derivation", () => { beforeEach(() => { vi.resetModules(); vi.stubGlobal("localStorage", createStorageMock()); vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + localStorage.clear(); + setControlUiBasePath(undefined); }); afterEach(() => { vi.restoreAllMocks(); + setControlUiBasePath(undefined); vi.unstubAllGlobals(); }); it("uses configured base path and normalizes trailing slash", async () => { - vi.stubGlobal("location", { + setTestLocation({ protocol: "https:", host: "gateway.example:8443", pathname: "/ignored/path", - } as Location); - vi.stubGlobal("window", { __OPENCLAW_CONTROL_UI_BASE_PATH__: " /openclaw/ " } as Window & - typeof globalThis); + }); + setControlUiBasePath(" /openclaw/ "); const { loadSettings } = await import("./storage.ts"); - expect(loadSettings().gatewayUrl).toBe("wss://gateway.example:8443/openclaw"); + expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/openclaw")); }); it("infers base path from nested pathname when configured base path is not set", async () => { - vi.stubGlobal("location", { + setTestLocation({ protocol: "http:", host: "gateway.example:18789", pathname: "/apps/openclaw/chat", - } as Location); - vi.stubGlobal("window", {} as Window & typeof globalThis); + }); const { loadSettings } = await import("./storage.ts"); - expect(loadSettings().gatewayUrl).toBe("ws://gateway.example:18789/apps/openclaw"); + expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw")); + }); + + it("ignores and scrubs legacy persisted tokens", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "persisted-token", + sessionKey: "agent", + }), + ); + + const { loadSettings } = await import("./storage.ts"); + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "", + sessionKey: "agent", + }); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + sessionKey: "agent", + lastActiveSessionKey: "agent", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + }); + + it("does not persist gateway tokens when saving settings", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "memory-only-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 757dc9eab7f..b413cf38eb5 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,5 +1,7 @@ const KEY = "openclaw.control.settings.v1"; +type PersistedUiSettings = Omit & { token?: never }; + import { isSupportedLocale } from "../i18n/index.ts"; import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts"; import type { ThemeMode } from "./theme.ts"; @@ -50,12 +52,13 @@ export function loadSettings(): UiSettings { return defaults; } const parsed = JSON.parse(raw) as Partial; - return { + const settings = { gatewayUrl: typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() ? parsed.gatewayUrl.trim() : defaults.gatewayUrl, - token: typeof parsed.token === "string" ? parsed.token : defaults.token, + // Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load. + token: defaults.token, sessionKey: typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() ? parsed.sessionKey.trim() @@ -89,11 +92,31 @@ export function loadSettings(): UiSettings { : defaults.navGroupsCollapsed, locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined, }; + if ("token" in parsed) { + persistSettings(settings); + } + return settings; } catch { return defaults; } } export function saveSettings(next: UiSettings) { - localStorage.setItem(KEY, JSON.stringify(next)); + persistSettings(next); +} + +function persistSettings(next: UiSettings) { + const persisted: PersistedUiSettings = { + gatewayUrl: next.gatewayUrl, + sessionKey: next.sessionKey, + lastActiveSessionKey: next.lastActiveSessionKey, + theme: next.theme, + chatFocusMode: next.chatFocusMode, + chatShowThinking: next.chatShowThinking, + splitRatio: next.splitRatio, + navCollapsed: next.navCollapsed, + navGroupsCollapsed: next.navGroupsCollapsed, + ...(next.locale ? { locale: next.locale } : {}), + }; + localStorage.setItem(KEY, JSON.stringify(persisted)); }