fix(gateway): honor insecure ws override for remote hostnames
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user