Skip to content

Commit 99eb3fd

Browse files
committed
fix(ui): keep shared auth on insecure control-ui connects
1 parent 3cf06f7 commit 99eb3fd

File tree

3 files changed

+80
-3
lines changed

3 files changed

+80
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ Docs: https://docs.openclaw.ai
2626
- 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.
2727
- 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.
2828
- 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.
29-
3029
- 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.
30+
- 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.
3131

3232
## 2026.3.12
3333

ui/src/ui/gateway.node.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ function getLatestWebSocket(): MockWebSocket {
113113
return ws;
114114
}
115115

116+
function stubInsecureCrypto() {
117+
vi.stubGlobal("crypto", {
118+
randomUUID: () => "req-insecure",
119+
});
120+
}
121+
116122
describe("GatewayBrowserClient", () => {
117123
beforeEach(() => {
118124
const storage = createStorageMock();
@@ -176,6 +182,72 @@ describe("GatewayBrowserClient", () => {
176182
expect(signedPayload).not.toContain("stored-device-token");
177183
});
178184

185+
it("sends explicit shared token on insecure first connect without cached device fallback", async () => {
186+
stubInsecureCrypto();
187+
const client = new GatewayBrowserClient({
188+
url: "ws://gateway.example:18789",
189+
token: "shared-auth-token",
190+
});
191+
192+
client.start();
193+
const ws = getLatestWebSocket();
194+
ws.emitOpen();
195+
ws.emitMessage({
196+
type: "event",
197+
event: "connect.challenge",
198+
payload: { nonce: "nonce-1" },
199+
});
200+
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
201+
202+
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
203+
id?: string;
204+
method?: string;
205+
params?: { auth?: { token?: string; password?: string; deviceToken?: string } };
206+
};
207+
expect(connectFrame.id).toBe("req-insecure");
208+
expect(connectFrame.method).toBe("connect");
209+
expect(connectFrame.params?.auth).toEqual({
210+
token: "shared-auth-token",
211+
password: undefined,
212+
deviceToken: undefined,
213+
});
214+
expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled();
215+
expect(signDevicePayloadMock).not.toHaveBeenCalled();
216+
});
217+
218+
it("sends explicit shared password on insecure first connect without cached device fallback", async () => {
219+
stubInsecureCrypto();
220+
const client = new GatewayBrowserClient({
221+
url: "ws://gateway.example:18789",
222+
password: "shared-password", // pragma: allowlist secret
223+
});
224+
225+
client.start();
226+
const ws = getLatestWebSocket();
227+
ws.emitOpen();
228+
ws.emitMessage({
229+
type: "event",
230+
event: "connect.challenge",
231+
payload: { nonce: "nonce-1" },
232+
});
233+
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
234+
235+
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
236+
id?: string;
237+
method?: string;
238+
params?: { auth?: { token?: string; password?: string; deviceToken?: string } };
239+
};
240+
expect(connectFrame.id).toBe("req-insecure");
241+
expect(connectFrame.method).toBe("connect");
242+
expect(connectFrame.params?.auth).toEqual({
243+
token: undefined,
244+
password: "shared-password", // pragma: allowlist secret
245+
deviceToken: undefined,
246+
});
247+
expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled();
248+
expect(signDevicePayloadMock).not.toHaveBeenCalled();
249+
});
250+
179251
it("uses cached device tokens only when no explicit shared auth is provided", async () => {
180252
const client = new GatewayBrowserClient({
181253
url: "ws://127.0.0.1:18789",

ui/src/ui/gateway.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,14 @@ export class GatewayBrowserClient {
244244

245245
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
246246
const role = "operator";
247+
const explicitGatewayToken = this.opts.token?.trim() || undefined;
248+
const explicitPassword = this.opts.password?.trim() || undefined;
247249
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
248-
let selectedAuth: SelectedConnectAuth = { canFallbackToShared: false };
250+
let selectedAuth: SelectedConnectAuth = {
251+
authToken: explicitGatewayToken,
252+
authPassword: explicitPassword,
253+
canFallbackToShared: false,
254+
};
249255

250256
if (isSecureContext) {
251257
deviceIdentity = await loadOrCreateDeviceIdentity();
@@ -257,7 +263,6 @@ export class GatewayBrowserClient {
257263
this.pendingDeviceTokenRetry = false;
258264
}
259265
}
260-
const explicitGatewayToken = this.opts.token?.trim() || undefined;
261266
const authToken = selectedAuth.authToken;
262267
const deviceToken = selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken;
263268
const auth =

0 commit comments

Comments
 (0)