fix(dashboard): keep gateway tokens out of URL storage
This commit is contained in:
@@ -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.<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.
|
||||
- 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -231,13 +231,14 @@ http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
|
||||
Optional one-time auth (if needed):
|
||||
|
||||
```text
|
||||
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789&token=<gateway-token>
|
||||
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-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.).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const KEY = "openclaw.control.settings.v1";
|
||||
|
||||
type PersistedUiSettings = Omit<UiSettings, "token"> & { 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<UiSettings>;
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user