fix(dashboard): keep gateway tokens out of URL storage

This commit is contained in:
Peter Steinberger
2026-03-07 18:33:19 +00:00
parent f966dde476
commit 10d0e3f3ca
10 changed files with 187 additions and 26 deletions

View File

@@ -602,6 +602,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### 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.<name>` 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. - Models/provider config precedence: prefer exact `models.providers.<name>` 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. - 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. - 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.

View File

@@ -661,18 +661,20 @@ extension GatewayEndpointStore {
components.path = "/" components.path = "/"
} }
var queryItems: [URLQueryItem] = [] var fragmentItems: [URLQueryItem] = []
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty !token.isEmpty
{ {
queryItems.append(URLQueryItem(name: "token", value: token)) fragmentItems.append(URLQueryItem(name: "token", value: token))
} }
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines), components.queryItems = nil
!password.isEmpty if fragmentItems.isEmpty {
{ components.fragment = nil
queryItems.append(URLQueryItem(name: "password", value: password)) } else {
var fragment = URLComponents()
fragment.queryItems = fragmentItems
components.fragment = fragment.percentEncodedQuery
} }
components.queryItems = queryItems.isEmpty ? nil : queryItems
guard let url = components.url else { guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [ throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL", NSLocalizedDescriptionKey: "Failed to build dashboard URL",

View File

@@ -216,6 +216,20 @@ import Testing
#expect(url.absoluteString == "https://gateway.example:443/remote-ui/") #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() { @Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1") let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1")
#expect(url?.port == 18789) #expect(url?.port == 18789)

View File

@@ -2503,7 +2503,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
Facts (from code): 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: Fix:

View File

@@ -231,13 +231,14 @@ http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
Optional one-time auth (if needed): Optional one-time auth (if needed):
```text ```text
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789&token=<gateway-token> http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-token>
``` ```
Notes: Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL. - `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. - 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. Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).

View File

@@ -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). (token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration).
Security note: the Control UI is an **admin surface** (chat, config, exec approvals). 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. Prefer localhost, Tailscale Serve, or an SSH tunnel.
## Fast path (recommended) ## Fast path (recommended)
@@ -36,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
## Token basics (local vs remote) ## Token basics (local vs remote)
- **Localhost**: open `http://127.0.0.1:18789/`. - **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 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. - 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). - **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).

View File

@@ -351,7 +351,7 @@ export async function finalizeOnboardingWizard(
"Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.", "Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.",
`View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`, `View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`,
`Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-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")}`, `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
"If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).", "If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).",
].join("\n"), ].join("\n"),

View File

@@ -151,6 +151,9 @@ describe("control UI routing", () => {
await app.updateComplete; await app.updateComplete;
expect(app.settings.token).toBe("abc123"); 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.pathname).toBe("/ui/overview");
expect(window.location.search).toBe(""); 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 () => { it("hydrates token from URL params even when settings already set", async () => {
localStorage.setItem( localStorage.setItem(
"openclaw.control.settings.v1", "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"); const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete; await app.updateComplete;
expect(app.settings.token).toBe("abc123"); 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.pathname).toBe("/ui/overview");
expect(window.location.search).toBe(""); expect(window.location.search).toBe("");
}); });
@@ -182,6 +191,9 @@ describe("control UI routing", () => {
await app.updateComplete; await app.updateComplete;
expect(app.settings.token).toBe("abc123"); 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.pathname).toBe("/ui/overview");
expect(window.location.hash).toBe(""); expect(window.location.hash).toBe("");
}); });

View File

@@ -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", () => { describe("loadSettings default gateway URL derivation", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock()); vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator); vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
localStorage.clear();
setControlUiBasePath(undefined);
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
setControlUiBasePath(undefined);
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("uses configured base path and normalizes trailing slash", async () => { it("uses configured base path and normalizes trailing slash", async () => {
vi.stubGlobal("location", { setTestLocation({
protocol: "https:", protocol: "https:",
host: "gateway.example:8443", host: "gateway.example:8443",
pathname: "/ignored/path", pathname: "/ignored/path",
} as Location); });
vi.stubGlobal("window", { __OPENCLAW_CONTROL_UI_BASE_PATH__: " /openclaw/ " } as Window & setControlUiBasePath(" /openclaw/ ");
typeof globalThis);
const { loadSettings } = await import("./storage.ts"); 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 () => { it("infers base path from nested pathname when configured base path is not set", async () => {
vi.stubGlobal("location", { setTestLocation({
protocol: "http:", protocol: "http:",
host: "gateway.example:18789", host: "gateway.example:18789",
pathname: "/apps/openclaw/chat", pathname: "/apps/openclaw/chat",
} as Location); });
vi.stubGlobal("window", {} as Window & typeof globalThis);
const { loadSettings } = await import("./storage.ts"); 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: {},
});
}); });
}); });

View File

@@ -1,5 +1,7 @@
const KEY = "openclaw.control.settings.v1"; const KEY = "openclaw.control.settings.v1";
type PersistedUiSettings = Omit<UiSettings, "token"> & { token?: never };
import { isSupportedLocale } from "../i18n/index.ts"; import { isSupportedLocale } from "../i18n/index.ts";
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts"; import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
import type { ThemeMode } from "./theme.ts"; import type { ThemeMode } from "./theme.ts";
@@ -50,12 +52,13 @@ export function loadSettings(): UiSettings {
return defaults; return defaults;
} }
const parsed = JSON.parse(raw) as Partial<UiSettings>; const parsed = JSON.parse(raw) as Partial<UiSettings>;
return { const settings = {
gatewayUrl: gatewayUrl:
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim() ? parsed.gatewayUrl.trim()
: defaults.gatewayUrl, : 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: sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim() ? parsed.sessionKey.trim()
@@ -89,11 +92,31 @@ export function loadSettings(): UiSettings {
: defaults.navGroupsCollapsed, : defaults.navGroupsCollapsed,
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined, locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
}; };
if ("token" in parsed) {
persistSettings(settings);
}
return settings;
} catch { } catch {
return defaults; return defaults;
} }
} }
export function saveSettings(next: UiSettings) { 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));
} }