diff --git a/CHANGELOG.md b/CHANGELOG.md index 218dd90ff..6e4e8d3e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. - Slack/local file upload allowlist parity: propagate `mediaLocalRoots` through the Slack send action pipeline so workspace-rooted attachments pass `assertLocalMediaAllowed` checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin. diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts index 984f9c0fc..58a2715c3 100644 --- a/src/commands/onboard-remote.test.ts +++ b/src/commands/onboard-remote.test.ts @@ -132,6 +132,27 @@ describe("promptRemoteGatewayConfig", () => { expect(next.gateway?.remote?.token).toBeUndefined(); }); + it("allows ws:// hostname remote URLs when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", async () => { + process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + expect(params.validate?.("ws://openclaw-gateway.ai:18789")).toBeUndefined(); + expect(params.validate?.("ws://1.1.1.1:18789")).toContain("Use wss://"); + return "ws://openclaw-gateway.ai:18789"; + } + return ""; + }) as WizardPrompter["text"]; + + const { next } = await runRemotePrompt({ + text, + confirm: false, + selectResponses: { "Gateway auth": "off" }, + }); + + expect(next.gateway?.mode).toBe("remote"); + expect(next.gateway?.remote?.url).toBe("ws://openclaw-gateway.ai:18789"); + }); + it("supports storing remote auth as an external env secret ref", async () => { process.env.OPENCLAW_GATEWAY_TOKEN = "remote-token-value"; const text: WizardPrompter["text"] = vi.fn(async (params) => { diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index d810121d3..7ab4cf7b2 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -468,6 +468,23 @@ describe("buildGatewayConnectionDetails", () => { expect(details.urlSource).toBe("config gateway.remote.url"); }); + it("allows ws:// hostname remote URLs when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { + process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; + loadConfig.mockReturnValue({ + gateway: { + mode: "remote", + bind: "loopback", + remote: { url: "ws://openclaw-gateway.ai:18789" }, + }, + }); + resolveGatewayPort.mockReturnValue(18789); + + const details = buildGatewayConnectionDetails(); + + expect(details.url).toBe("ws://openclaw-gateway.ai:18789"); + expect(details.urlSource).toBe("config gateway.remote.url"); + }); + it("allows ws:// for loopback addresses in local mode", () => { setLocalLoopbackGatewayConfig(); diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index e6e38693e..c69cbef39 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -228,6 +228,21 @@ describe("GatewayClient security checks", () => { expect(wsInstances.length).toBe(1); client.stop(); }); + + it("allows ws:// hostnames with OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { + process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; + const onConnectError = vi.fn(); + const client = new GatewayClient({ + url: "ws://openclaw-gateway.ai:18789", + onConnectError, + }); + + client.start(); + + expect(onConnectError).not.toHaveBeenCalled(); + expect(wsInstances.length).toBe(1); + client.stop(); + }); }); describe("GatewayClient close handling", () => { diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 3ab82c85a..1faf727a8 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -457,6 +457,7 @@ describe("isSecureWebSocketUrl", () => { "ws://169.254.10.20:18789", "ws://[fc00::1]:18789", "ws://[fe80::1]:18789", + "ws://gateway.private.example:18789", ]; for (const input of allowedWhenOptedIn) { @@ -464,6 +465,14 @@ describe("isSecureWebSocketUrl", () => { } }); + it("still rejects ws:// public IP literals when opt-in is enabled", () => { + const publicIpWsUrls = ["ws://1.1.1.1:18789", "ws://8.8.8.8:18789", "ws://203.0.113.10:18789"]; + + for (const input of publicIpWsUrls) { + expect(isSecureWebSocketUrl(input, { allowPrivateWs: true }), input).toBe(false); + } + }); + it("still rejects non-unicast IPv6 ws:// even when opt-in is enabled", () => { const disallowedWhenOptedIn = [ "ws://[::]:18789", diff --git a/src/gateway/net.ts b/src/gateway/net.ts index b4d647a48..d57915fdc 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -435,7 +435,16 @@ export function isSecureWebSocketUrl( } // Optional break-glass for trusted private-network overlays. if (opts?.allowPrivateWs) { - return isPrivateOrLoopbackHost(parsed.hostname); + if (isPrivateOrLoopbackHost(parsed.hostname)) { + return true; + } + // Hostnames may resolve to private networks (for example in VPN/Tailnet DNS), + // but resolution is not available in this synchronous validator. + const hostForIpCheck = + parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]") + ? parsed.hostname.slice(1, -1) + : parsed.hostname; + return net.isIP(hostForIpCheck) === 0; } return false; }