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
This commit is contained in:
Pavan Kumar Gondhi
2026-05-12 16:06:00 +05:30
committed by GitHub
parent fd12a48ee1
commit 5ef8d1ab5e
3 changed files with 83 additions and 3 deletions

View File

@@ -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.

View File

@@ -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({

View File

@@ -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;
}