mirror of https://github.com/openclaw/openclaw.git
fix(ui): keep shared auth on insecure control-ui connects (#45088)
Merged via squash.
Prepared head SHA: 99eb3fd928
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
This commit is contained in:
parent
3cf06f7939
commit
0a3b9a9a09
|
|
@ -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.
|
- 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.
|
- 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: 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.
|
- 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
|
## 2026.3.12
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,12 @@ function getLatestWebSocket(): MockWebSocket {
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stubInsecureCrypto() {
|
||||||
|
vi.stubGlobal("crypto", {
|
||||||
|
randomUUID: () => "req-insecure",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("GatewayBrowserClient", () => {
|
describe("GatewayBrowserClient", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const storage = createStorageMock();
|
const storage = createStorageMock();
|
||||||
|
|
@ -176,6 +182,72 @@ describe("GatewayBrowserClient", () => {
|
||||||
expect(signedPayload).not.toContain("stored-device-token");
|
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 () => {
|
it("uses cached device tokens only when no explicit shared auth is provided", async () => {
|
||||||
const client = new GatewayBrowserClient({
|
const client = new GatewayBrowserClient({
|
||||||
url: "ws://127.0.0.1:18789",
|
url: "ws://127.0.0.1:18789",
|
||||||
|
|
|
||||||
|
|
@ -244,8 +244,14 @@ export class GatewayBrowserClient {
|
||||||
|
|
||||||
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||||
const role = "operator";
|
const role = "operator";
|
||||||
|
const explicitGatewayToken = this.opts.token?.trim() || undefined;
|
||||||
|
const explicitPassword = this.opts.password?.trim() || undefined;
|
||||||
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||||
let selectedAuth: SelectedConnectAuth = { canFallbackToShared: false };
|
let selectedAuth: SelectedConnectAuth = {
|
||||||
|
authToken: explicitGatewayToken,
|
||||||
|
authPassword: explicitPassword,
|
||||||
|
canFallbackToShared: false,
|
||||||
|
};
|
||||||
|
|
||||||
if (isSecureContext) {
|
if (isSecureContext) {
|
||||||
deviceIdentity = await loadOrCreateDeviceIdentity();
|
deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||||
|
|
@ -257,7 +263,6 @@ export class GatewayBrowserClient {
|
||||||
this.pendingDeviceTokenRetry = false;
|
this.pendingDeviceTokenRetry = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const explicitGatewayToken = this.opts.token?.trim() || undefined;
|
|
||||||
const authToken = selectedAuth.authToken;
|
const authToken = selectedAuth.authToken;
|
||||||
const deviceToken = selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken;
|
const deviceToken = selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken;
|
||||||
const auth =
|
const auth =
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue