From 0a3b9a9a090ab2ae1dbf6eb8d044e20af165c0d1 Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Fri, 13 Mar 2026 14:25:31 +0100 Subject: [PATCH] fix(ui): keep shared auth on insecure control-ui connects (#45088) Merged via squash. Prepared head SHA: 99eb3fd9281549a4e012b63eb9608dc47455ad03 Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 2 +- ui/src/ui/gateway.node.test.ts | 72 ++++++++++++++++++++++++++++++++++ ui/src/ui/gateway.ts | 9 ++++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eb29e1b79b..5fa88373053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,8 @@ Docs: https://docs.openclaw.ai - Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates. - - Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path. +- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. ## 2026.3.12 diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 42d5e598245..dfc32562768 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -113,6 +113,12 @@ function getLatestWebSocket(): MockWebSocket { return ws; } +function stubInsecureCrypto() { + vi.stubGlobal("crypto", { + randomUUID: () => "req-insecure", + }); +} + describe("GatewayBrowserClient", () => { beforeEach(() => { const storage = createStorageMock(); @@ -176,6 +182,72 @@ describe("GatewayBrowserClient", () => { expect(signedPayload).not.toContain("stored-device-token"); }); + it("sends explicit shared token on insecure first connect without cached device fallback", async () => { + stubInsecureCrypto(); + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + token: "shared-auth-token", + }); + + client.start(); + const ws = getLatestWebSocket(); + ws.emitOpen(); + ws.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-1" }, + }); + await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0)); + + const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { + id?: string; + method?: string; + params?: { auth?: { token?: string; password?: string; deviceToken?: string } }; + }; + expect(connectFrame.id).toBe("req-insecure"); + expect(connectFrame.method).toBe("connect"); + expect(connectFrame.params?.auth).toEqual({ + token: "shared-auth-token", + password: undefined, + deviceToken: undefined, + }); + expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled(); + expect(signDevicePayloadMock).not.toHaveBeenCalled(); + }); + + it("sends explicit shared password on insecure first connect without cached device fallback", async () => { + stubInsecureCrypto(); + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + password: "shared-password", // pragma: allowlist secret + }); + + client.start(); + const ws = getLatestWebSocket(); + ws.emitOpen(); + ws.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-1" }, + }); + await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0)); + + const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { + id?: string; + method?: string; + params?: { auth?: { token?: string; password?: string; deviceToken?: string } }; + }; + expect(connectFrame.id).toBe("req-insecure"); + expect(connectFrame.method).toBe("connect"); + expect(connectFrame.params?.auth).toEqual({ + token: undefined, + password: "shared-password", // pragma: allowlist secret + deviceToken: undefined, + }); + expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled(); + expect(signDevicePayloadMock).not.toHaveBeenCalled(); + }); + it("uses cached device tokens only when no explicit shared auth is provided", async () => { const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 7c958079516..6f628b619ab 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -244,8 +244,14 @@ export class GatewayBrowserClient { const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; const role = "operator"; + const explicitGatewayToken = this.opts.token?.trim() || undefined; + const explicitPassword = this.opts.password?.trim() || undefined; let deviceIdentity: Awaited> | null = null; - let selectedAuth: SelectedConnectAuth = { canFallbackToShared: false }; + let selectedAuth: SelectedConnectAuth = { + authToken: explicitGatewayToken, + authPassword: explicitPassword, + canFallbackToShared: false, + }; if (isSecureContext) { deviceIdentity = await loadOrCreateDeviceIdentity(); @@ -257,7 +263,6 @@ export class GatewayBrowserClient { this.pendingDeviceTokenRetry = false; } } - const explicitGatewayToken = this.opts.token?.trim() || undefined; const authToken = selectedAuth.authToken; const deviceToken = selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken; const auth =