From 5ef8d1ab5ee2ae6cedeeff0f623e7a6b78ff6cbf Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Tue, 12 May 2026 16:06:00 +0530 Subject: [PATCH] Validate Control UI loopback retry endpoints [AI] (#80900) * fix: validate loopback retry endpoints * fix: validate loopback retry endpoints * docs: add changelog entry for PR merge --- CHANGELOG.md | 1 + ui/src/ui/gateway.node.test.ts | 66 ++++++++++++++++++++++++++++++++++ ui/src/ui/gateway.ts | 19 ++++++++-- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdf71abbd5..af5e7bd091d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Validate Control UI loopback retry endpoints [AI]. (#80900) Thanks @pgondhi987. - Harden exported markdown link rendering [AI]. (#80902) Thanks @pgondhi987. - fix(gateway): honor minimal discovery mode for wide-area DNS-SD [AI]. (#80903) Thanks @pgondhi987. - slack: enforce reaction notification policy [AI]. (#80907) Thanks @pgondhi987. diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 98975aaea95..f7f9c270ccc 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -623,6 +623,34 @@ describe("GatewayBrowserClient", () => { vi.useRealTimers(); }); + it("reconnects without cached device token for DNS hosts beginning with a 127 label", async () => { + useNodeFakeTimers(); + const client = new GatewayBrowserClient({ + url: "ws://127.example.invalid:18789", + token: "shared-auth-token", + }); + + try { + const { ws: firstWs, connectFrame: firstConnect } = await startConnect(client); + expect(firstConnect.params?.auth?.token).toBe("shared-auth-token"); + expect(firstConnect.params?.auth?.deviceToken).toBeUndefined(); + + emitRetryableTokenMismatch(firstWs, firstConnect.id); + await expectSocketClosed(firstWs); + firstWs.emitClose(4008, "connect failed"); + + await vi.advanceTimersByTimeAsync(800); + const secondWs = getLatestWebSocket(); + expect(secondWs).not.toBe(firstWs); + const { connectFrame: secondConnect } = await continueConnect(secondWs, "nonce-2"); + expect(secondConnect.params?.auth?.token).toBe("shared-auth-token"); + expect(secondConnect.params?.auth?.deviceToken).toBeUndefined(); + } finally { + client.stop(); + vi.useRealTimers(); + } + }); + it("retries startup-unavailable connect responses without terminal callbacks", async () => { useNodeFakeTimers(); const onClose = vi.fn(); @@ -910,6 +938,44 @@ describe("shouldRetryWithDeviceToken", () => { ).toBe(true); }); + it("allows a bounded retry for loopback IPv4 addresses in the 127 block", () => { + expect( + shouldRetryWithDeviceToken({ + deviceTokenRetryBudgetUsed: false, + authDeviceToken: undefined, + explicitGatewayToken: "shared-auth-token", + deviceIdentity: { + deviceId: "device-1", + privateKey: "private-key", // pragma: allowlist secret + publicKey: "public-key", // pragma: allowlist secret + }, + storedToken: "stored-device-token", + canRetryWithDeviceTokenHint: true, + url: "ws://127.255.10.42:18789", + }), + ).toBe(true); + }); + + it("blocks the retry for DNS hosts beginning with a 127 label", () => { + for (const url of ["ws://127.example.invalid:18789", "ws://127.0.0.1.example.invalid:18789"]) { + expect( + shouldRetryWithDeviceToken({ + deviceTokenRetryBudgetUsed: false, + authDeviceToken: undefined, + explicitGatewayToken: "shared-auth-token", + deviceIdentity: { + deviceId: "device-1", + privateKey: "private-key", // pragma: allowlist secret + publicKey: "public-key", // pragma: allowlist secret + }, + storedToken: "stored-device-token", + canRetryWithDeviceTokenHint: true, + url, + }), + ).toBe(false); + } + }); + it("blocks the retry after the one-shot budget is spent", () => { expect( shouldRetryWithDeviceToken({ diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index fdaa1e82e52..6f836d39db1 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -98,13 +98,26 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): ); } +function isLoopbackIPv4Host(host: string): boolean { + const octets = host.split("."); + if (octets.length !== 4 || octets[0] !== "127") { + return false; + } + return octets.every((octet) => { + if (!/^\d+$/.test(octet)) { + return false; + } + const value = Number(octet); + return value >= 0 && value <= 255; + }); +} + function isTrustedRetryEndpoint(url: string): boolean { try { const gatewayUrl = new URL(url, window.location.href); const host = gatewayUrl.hostname.trim().toLowerCase(); - const isLoopbackHost = - host === "localhost" || host === "::1" || host === "[::1]" || host === "127.0.0.1"; - const isLoopbackIPv4 = host.startsWith("127."); + const isLoopbackHost = host === "localhost" || host === "::1" || host === "[::1]"; + const isLoopbackIPv4 = isLoopbackIPv4Host(host); if (isLoopbackHost || isLoopbackIPv4) { return true; }