mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:18:28 +00:00
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.
This commit is contained in:
@@ -31,9 +31,13 @@ vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4,
|
||||
}));
|
||||
|
||||
vi.mock("./net.js", () => ({
|
||||
pickPrimaryLanIPv4,
|
||||
}));
|
||||
vi.mock("./net.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./net.js")>();
|
||||
return {
|
||||
...actual,
|
||||
pickPrimaryLanIPv4,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
describeGatewayCloseCode: (code: number) => {
|
||||
@@ -91,7 +95,7 @@ function makeRemotePasswordGatewayConfig(remotePassword: string, localPassword =
|
||||
return {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: { url: "ws://remote.example:18789", password: remotePassword },
|
||||
remote: { url: "wss://remote.example:18789", password: remotePassword },
|
||||
auth: { password: localPassword },
|
||||
},
|
||||
};
|
||||
@@ -122,25 +126,46 @@ describe("callGateway url resolution", () => {
|
||||
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800");
|
||||
});
|
||||
|
||||
it("uses tailnet IP when local bind is tailnet and tailnet is present", async () => {
|
||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } });
|
||||
it("uses tailnet IP with TLS when local bind is tailnet", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: { mode: "local", bind: "tailnet", tls: { enabled: true } },
|
||||
});
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
|
||||
|
||||
await callGateway({ method: "health" });
|
||||
|
||||
expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800");
|
||||
expect(lastClientOptions?.url).toBe("wss://100.64.0.1:18800");
|
||||
});
|
||||
|
||||
it("uses LAN IP when bind is lan and LAN IP is available", async () => {
|
||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan" } });
|
||||
it("blocks ws:// to tailnet IP without TLS (CWE-319)", async () => {
|
||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } });
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
|
||||
|
||||
await expect(callGateway({ method: "health" })).rejects.toThrow("SECURITY ERROR");
|
||||
});
|
||||
|
||||
it("uses LAN IP with TLS when bind is lan", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: { mode: "local", bind: "lan", tls: { enabled: true } },
|
||||
});
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||
pickPrimaryLanIPv4.mockReturnValue("192.168.1.42");
|
||||
|
||||
await callGateway({ method: "health" });
|
||||
|
||||
expect(lastClientOptions?.url).toBe("ws://192.168.1.42:18800");
|
||||
expect(lastClientOptions?.url).toBe("wss://192.168.1.42:18800");
|
||||
});
|
||||
|
||||
it("blocks ws:// to LAN IP without TLS (CWE-319)", async () => {
|
||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan" } });
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||
pickPrimaryLanIPv4.mockReturnValue("192.168.1.42");
|
||||
|
||||
await expect(callGateway({ method: "health" })).rejects.toThrow("SECURITY ERROR");
|
||||
});
|
||||
|
||||
it("falls back to loopback when bind is lan but no LAN IP found", async () => {
|
||||
@@ -214,9 +239,9 @@ describe("buildGatewayConnectionDetails", () => {
|
||||
expect(details.message).toContain("Gateway target: ws://127.0.0.1:18789");
|
||||
});
|
||||
|
||||
it("uses LAN IP and reports lan source when bind is lan", () => {
|
||||
it("uses LAN IP with TLS and reports lan source when bind is lan", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: { mode: "local", bind: "lan" },
|
||||
gateway: { mode: "local", bind: "lan", tls: { enabled: true } },
|
||||
});
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||
@@ -224,11 +249,22 @@ describe("buildGatewayConnectionDetails", () => {
|
||||
|
||||
const details = buildGatewayConnectionDetails();
|
||||
|
||||
expect(details.url).toBe("ws://10.0.0.5:18800");
|
||||
expect(details.url).toBe("wss://10.0.0.5:18800");
|
||||
expect(details.urlSource).toBe("local lan 10.0.0.5");
|
||||
expect(details.bindDetail).toBe("Bind: lan");
|
||||
});
|
||||
|
||||
it("throws for ws:// to LAN IP without TLS (CWE-319)", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: { mode: "local", bind: "lan" },
|
||||
});
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||
pickPrimaryLanIPv4.mockReturnValue("10.0.0.5");
|
||||
|
||||
expect(() => buildGatewayConnectionDetails()).toThrow("SECURITY ERROR");
|
||||
});
|
||||
|
||||
it("prefers remote url when configured", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
@@ -247,6 +283,34 @@ describe("buildGatewayConnectionDetails", () => {
|
||||
expect(details.bindDetail).toBeUndefined();
|
||||
expect(details.remoteFallbackNote).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws for insecure ws:// remote URLs (CWE-319)", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
bind: "loopback",
|
||||
remote: { url: "ws://remote.example.com:18789" },
|
||||
},
|
||||
});
|
||||
resolveGatewayPort.mockReturnValue(18789);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||
|
||||
expect(() => buildGatewayConnectionDetails()).toThrow("SECURITY ERROR");
|
||||
expect(() => buildGatewayConnectionDetails()).toThrow("plaintext ws://");
|
||||
expect(() => buildGatewayConnectionDetails()).toThrow("wss://");
|
||||
});
|
||||
|
||||
it("allows ws:// for loopback addresses in local mode", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: { mode: "local", bind: "loopback" },
|
||||
});
|
||||
resolveGatewayPort.mockReturnValue(18789);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||
|
||||
const details = buildGatewayConnectionDetails();
|
||||
|
||||
expect(details.url).toBe("ws://127.0.0.1:18789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callGateway error details", () => {
|
||||
|
||||
Reference in New Issue
Block a user