fix(gateway): honor insecure ws override for remote hostnames

This commit is contained in:
Vignesh Natarajan
2026-03-05 17:04:26 -08:00
parent c260e207b2
commit d86a12eb62
6 changed files with 73 additions and 1 deletions

View File

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

View File

@@ -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) => {

View File

@@ -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();

View File

@@ -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", () => {

View File

@@ -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",

View File

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