Files
openclaw/src/gateway/client.test.ts
Jay Caldwell 9edec67a18 fix(security): block plaintext WebSocket connections to non-loopback addresses (#20803)
* fix(security): block plaintext WebSocket connections to non-loopback addresses

Addresses CWE-319 (Cleartext Transmission of Sensitive Information).

Previously, ws:// connections to remote hosts were allowed, exposing
both credentials and chat data to network interception. This change
blocks ALL plaintext ws:// connections to non-loopback addresses,
regardless of whether explicit credentials are configured (device
tokens may be loaded dynamically).

Security policy:
- wss:// allowed to any host
- ws:// allowed only to loopback (127.x.x.x, localhost, ::1)
- ws:// to LAN/tailnet/remote hosts now requires TLS

Changes:
- Add isSecureWebSocketUrl() validation in net.ts
- Block insecure connections in GatewayClient.start()
- Block insecure URLs in buildGatewayConnectionDetails()
- Handle malformed URLs gracefully without crashing
- Update tests to use wss:// for non-loopback URLs

Fixes #12519

* fix(test): update gateway-chat mock to preserve net.js exports

Use importOriginal to spread actual module exports and mock only
the functions needed for testing. This ensures isSecureWebSocketUrl
and other exports remain available to the code under test.
2026-02-19 03:13:08 -08:00

222 lines
6.2 KiB
TypeScript

import { Buffer } from "node:buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DeviceIdentity } from "../infra/device-identity.js";
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
const logDebugMock = vi.hoisted(() => vi.fn());
type WsEvent = "open" | "message" | "close" | "error";
type WsEventHandlers = {
open: () => void;
message: (data: string | Buffer) => void;
close: (code: number, reason: Buffer) => void;
error: (err: unknown) => void;
};
class MockWebSocket {
private openHandlers: WsEventHandlers["open"][] = [];
private messageHandlers: WsEventHandlers["message"][] = [];
private closeHandlers: WsEventHandlers["close"][] = [];
private errorHandlers: WsEventHandlers["error"][] = [];
constructor(_url: string, _options?: unknown) {
wsInstances.push(this);
}
on(event: "open", handler: WsEventHandlers["open"]): void;
on(event: "message", handler: WsEventHandlers["message"]): void;
on(event: "close", handler: WsEventHandlers["close"]): void;
on(event: "error", handler: WsEventHandlers["error"]): void;
on(event: WsEvent, handler: WsEventHandlers[WsEvent]): void {
switch (event) {
case "open":
this.openHandlers.push(handler as WsEventHandlers["open"]);
return;
case "message":
this.messageHandlers.push(handler as WsEventHandlers["message"]);
return;
case "close":
this.closeHandlers.push(handler as WsEventHandlers["close"]);
return;
case "error":
this.errorHandlers.push(handler as WsEventHandlers["error"]);
return;
default:
return;
}
}
close(_code?: number, _reason?: string): void {}
emitClose(code: number, reason: string): void {
for (const handler of this.closeHandlers) {
handler(code, Buffer.from(reason));
}
}
}
vi.mock("ws", () => ({
WebSocket: MockWebSocket,
}));
vi.mock("../infra/device-auth-store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/device-auth-store.js")>();
return {
...actual,
clearDeviceAuthToken: (...args: unknown[]) => clearDeviceAuthTokenMock(...args),
};
});
vi.mock("../logger.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../logger.js")>();
return {
...actual,
logDebug: (...args: unknown[]) => logDebugMock(...args),
};
});
const { GatewayClient } = await import("./client.js");
function getLatestWs(): MockWebSocket {
const ws = wsInstances.at(-1);
if (!ws) {
throw new Error("missing mock websocket instance");
}
return ws;
}
describe("GatewayClient security checks", () => {
beforeEach(() => {
wsInstances.length = 0;
});
it("blocks ws:// to non-loopback addresses (CWE-319)", () => {
const onConnectError = vi.fn();
const client = new GatewayClient({
url: "ws://remote.example.com:18789",
onConnectError,
});
client.start();
expect(onConnectError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("SECURITY ERROR"),
}),
);
expect(wsInstances.length).toBe(0); // No WebSocket created
client.stop();
});
it("handles malformed URLs gracefully without crashing", () => {
const onConnectError = vi.fn();
const client = new GatewayClient({
url: "not-a-valid-url",
onConnectError,
});
// Should not throw
expect(() => client.start()).not.toThrow();
expect(onConnectError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("SECURITY ERROR"),
}),
);
expect(wsInstances.length).toBe(0); // No WebSocket created
client.stop();
});
it("allows ws:// to loopback addresses", () => {
const onConnectError = vi.fn();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
onConnectError,
});
client.start();
expect(onConnectError).not.toHaveBeenCalled();
expect(wsInstances.length).toBe(1); // WebSocket created
client.stop();
});
it("allows wss:// to any address", () => {
const onConnectError = vi.fn();
const client = new GatewayClient({
url: "wss://remote.example.com:18789",
onConnectError,
});
client.start();
expect(onConnectError).not.toHaveBeenCalled();
expect(wsInstances.length).toBe(1); // WebSocket created
client.stop();
});
});
describe("GatewayClient close handling", () => {
beforeEach(() => {
wsInstances.length = 0;
clearDeviceAuthTokenMock.mockReset();
logDebugMock.mockReset();
});
it("clears stale token on device token mismatch close", () => {
const onClose = vi.fn();
const identity: DeviceIdentity = {
deviceId: "dev-1",
privateKeyPem: "private-key",
publicKeyPem: "public-key",
};
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
deviceIdentity: identity,
onClose,
});
client.start();
getLatestWs().emitClose(
1008,
"unauthorized: DEVICE token mismatch (rotate/reissue device token)",
);
expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({ deviceId: "dev-1", role: "operator" });
expect(onClose).toHaveBeenCalledWith(
1008,
"unauthorized: DEVICE token mismatch (rotate/reissue device token)",
);
client.stop();
});
it("does not break close flow when token clear throws", () => {
clearDeviceAuthTokenMock.mockImplementation(() => {
throw new Error("disk unavailable");
});
const onClose = vi.fn();
const identity: DeviceIdentity = {
deviceId: "dev-2",
privateKeyPem: "private-key",
publicKeyPem: "public-key",
};
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
deviceIdentity: identity,
onClose,
});
client.start();
expect(() => {
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
}).not.toThrow();
expect(logDebugMock).toHaveBeenCalledWith(
expect.stringContaining("failed clearing stale device-auth token"),
);
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
client.stop();
});
});