fix(gateway): allow ws:// to private network addresses (#28670)

* fix(gateway): allow ws:// to RFC 1918 private network addresses

resolve ws-private-network conflicts

* gateway: keep ws security strict-by-default with private opt-in

* gateway: apply private ws opt-in in connection detail guard

* gateway: apply private ws opt-in in websocket client

* onboarding: gate private ws urls behind explicit opt-in

* gateway tests: enforce strict ws defaults with private opt-in

* onboarding tests: validate private ws opt-in behavior

* gateway client tests: cover private ws env override

* gateway call tests: cover private ws env override

* changelog: add ws strict-default security entry for pr 28670

* docs(onboard): document private ws break-glass env

* docs(gateway): add private ws env to remote guide

* docs(docker): add private ws break-glass env var

* docs(security): add private ws break-glass guidance

* docs(config): document OPENCLAW_ALLOW_PRIVATE_WS

* Update CHANGELOG.md

* gateway: normalize private-ws host classification

* test(gateway): cover non-unicast ipv6 private-ws edges

* changelog: rename insecure private ws break-glass env

* docs(onboard): rename insecure private ws env

* docs(gateway): rename insecure private ws env in config reference

* docs(gateway): rename insecure private ws env in remote guide

* docs(security): rename insecure private ws env

* docs(docker): rename insecure private ws env

* test(onboard): rename insecure private ws env

* onboard: rename insecure private ws env

* test(gateway): rename insecure private ws env in call tests

* gateway: rename insecure private ws env in call flow

* test(gateway): rename insecure private ws env in client tests

* gateway: rename insecure private ws env in client

* docker: pass insecure private ws env to services

* docker-setup: persist insecure private ws env

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Alberto Leal
2026-03-01 23:49:45 -05:00
committed by GitHub
parent d76b224e20
commit 449511484d
16 changed files with 272 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import { captureEnv } from "../test-utils/env.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { createWizardPrompter } from "./test-wizard-helpers.js";
@@ -27,8 +28,11 @@ function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
}
describe("promptRemoteGatewayConfig", () => {
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
beforeEach(() => {
vi.clearAllMocks();
envSnapshot.restore();
detectBinary.mockResolvedValue(false);
discoverGatewayBeacons.mockResolvedValue([]);
resolveWideAreaDiscoveryDomain.mockReturnValue(undefined);
@@ -88,9 +92,12 @@ describe("promptRemoteGatewayConfig", () => {
);
});
it("validates insecure ws:// remote URLs and allows loopback ws://", async () => {
it("validates insecure ws:// remote URLs and allows only loopback ws:// by default", async () => {
const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Gateway WebSocket URL") {
// ws:// to public IPs is rejected
expect(params.validate?.("ws://203.0.113.10:18789")).toContain("Use wss://");
// ws:// to private IPs remains blocked by default
expect(params.validate?.("ws://10.0.0.8:18789")).toContain("Use wss://");
expect(params.validate?.("ws://127.0.0.1:18789")).toBeUndefined();
expect(params.validate?.("wss://remote.example.com:18789")).toBeUndefined();
@@ -119,4 +126,34 @@ describe("promptRemoteGatewayConfig", () => {
expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789");
expect(next.gateway?.remote?.token).toBeUndefined();
});
it("allows private ws:// only 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://10.0.0.8:18789")).toBeUndefined();
return "ws://10.0.0.8:18789";
}
return "";
}) as WizardPrompter["text"];
const select: WizardPrompter["select"] = vi.fn(async (params) => {
if (params.message === "Gateway auth") {
return "off" as never;
}
return (params.options[0]?.value ?? "") as never;
});
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => false),
select,
text,
});
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.remote?.url).toBe("ws://10.0.0.8:18789");
});
});

View File

@@ -35,8 +35,15 @@ function validateGatewayWebSocketUrl(value: string): string | undefined {
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) {
return "URL must start with ws:// or wss://";
}
if (!isSecureWebSocketUrl(trimmed)) {
return "Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel.";
if (
!isSecureWebSocketUrl(trimmed, {
allowPrivateWs: process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1",
})
) {
return (
"Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel. " +
"Break-glass: OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 for trusted private networks."
);
}
return undefined;
}

View File

@@ -90,10 +90,17 @@ function makeRemotePasswordGatewayConfig(remotePassword: string, localPassword =
}
describe("callGateway url resolution", () => {
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
beforeEach(() => {
envSnapshot.restore();
resetGatewayCallMocks();
});
afterEach(() => {
envSnapshot.restore();
});
it.each([
{
label: "keeps loopback when local bind is auto even if tailnet is present",
@@ -318,6 +325,23 @@ describe("buildGatewayConnectionDetails", () => {
expect((thrown as Error).message).toContain("openclaw doctor --fix");
});
it("allows ws:// private remote URLs only 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://10.0.0.8:18789" },
},
});
resolveGatewayPort.mockReturnValue(18789);
const details = buildGatewayConnectionDetails();
expect(details.url).toBe("ws://10.0.0.8:18789");
expect(details.urlSource).toBe("config gateway.remote.url");
});
it("allows ws:// for loopback addresses in local mode", () => {
setLocalLoopbackGatewayConfig();

View File

@@ -140,10 +140,11 @@ export function buildGatewayConnectionDetails(
: undefined;
const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined;
const allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1";
// Security check: block ALL insecure ws:// to non-loopback addresses (CWE-319, CVSS 9.8)
// This applies to the FINAL resolved URL, regardless of source (config, CLI override, etc).
// Both credentials and chat/conversation data must not be transmitted over plaintext to remote hosts.
if (!isSecureWebSocketUrl(url)) {
if (!isSecureWebSocketUrl(url, { allowPrivateWs })) {
throw new Error(
[
`SECURITY ERROR: Gateway URL "${url}" uses plaintext ws:// to a non-loopback address.`,
@@ -154,6 +155,9 @@ export function buildGatewayConnectionDetails(
"Safe remote access defaults:",
"- keep gateway.bind=loopback and use an SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@gateway-host)",
"- or use Tailscale Serve/Funnel for HTTPS remote access",
allowPrivateWs
? undefined
: "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1",
"Doctor: openclaw doctor --fix",
"Docs: https://docs.openclaw.ai/gateway/remote",
].join("\n"),

View File

@@ -1,6 +1,7 @@
import { Buffer } from "node:buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DeviceIdentity } from "../infra/device-identity.js";
import { captureEnv } from "../test-utils/env.js";
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
@@ -149,7 +150,10 @@ function expectSecurityConnectError(
}
describe("GatewayClient security checks", () => {
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
beforeEach(() => {
envSnapshot.restore();
wsInstances.length = 0;
});
@@ -209,6 +213,21 @@ describe("GatewayClient security checks", () => {
expect(wsInstances.length).toBe(1); // WebSocket created
client.stop();
});
it("allows ws:// to private addresses only 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://192.168.1.100:18789",
onConnectError,
});
client.start();
expect(onConnectError).not.toHaveBeenCalled();
expect(wsInstances.length).toBe(1);
client.stop();
});
});
describe("GatewayClient close handling", () => {

View File

@@ -114,10 +114,11 @@ export class GatewayClient {
return;
}
const allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1";
// Security check: block ALL plaintext ws:// to non-loopback addresses (CWE-319, CVSS 9.8)
// This protects both credentials AND chat/conversation data from MITM attacks.
// Device tokens may be loaded later in sendConnect(), so we block regardless of hasCredentials.
if (!isSecureWebSocketUrl(url)) {
if (!isSecureWebSocketUrl(url, { allowPrivateWs })) {
// Safe hostname extraction - avoid throwing on malformed URLs in error path
let displayHost = url;
try {
@@ -130,6 +131,9 @@ export class GatewayClient {
"Both credentials and chat data would be exposed to network interception. " +
"Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel " +
"(ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. " +
(allowPrivateWs
? ""
: "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1. ") +
"Run `openclaw doctor --fix` for guidance.",
);
this.opts.onConnectError?.(error);

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
isLocalishHost,
isPrivateOrLoopbackAddress,
isPrivateOrLoopbackHost,
isSecureWebSocketUrl,
isTrustedProxyAddress,
pickPrimaryLanIPv4,
@@ -349,21 +350,93 @@ describe("isPrivateOrLoopbackAddress", () => {
});
});
describe("isPrivateOrLoopbackHost", () => {
it("accepts localhost", () => {
expect(isPrivateOrLoopbackHost("localhost")).toBe(true);
});
it("accepts loopback addresses", () => {
expect(isPrivateOrLoopbackHost("127.0.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("::1")).toBe(true);
expect(isPrivateOrLoopbackHost("[::1]")).toBe(true);
});
it("accepts RFC 1918 private addresses", () => {
expect(isPrivateOrLoopbackHost("10.0.0.5")).toBe(true);
expect(isPrivateOrLoopbackHost("10.42.1.100")).toBe(true);
expect(isPrivateOrLoopbackHost("172.16.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("172.31.255.254")).toBe(true);
expect(isPrivateOrLoopbackHost("192.168.1.100")).toBe(true);
});
it("accepts CGNAT and link-local addresses", () => {
expect(isPrivateOrLoopbackHost("100.64.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("169.254.10.20")).toBe(true);
});
it("accepts IPv6 private addresses", () => {
expect(isPrivateOrLoopbackHost("[fc00::1]")).toBe(true);
expect(isPrivateOrLoopbackHost("[fd12:3456:789a::1]")).toBe(true);
expect(isPrivateOrLoopbackHost("[fe80::1]")).toBe(true);
});
it("rejects unspecified IPv6 address (::)", () => {
expect(isPrivateOrLoopbackHost("[::]")).toBe(false);
expect(isPrivateOrLoopbackHost("::")).toBe(false);
expect(isPrivateOrLoopbackHost("0:0::0")).toBe(false);
expect(isPrivateOrLoopbackHost("[0:0::0]")).toBe(false);
expect(isPrivateOrLoopbackHost("[0000:0000:0000:0000:0000:0000:0000:0000]")).toBe(false);
});
it("rejects multicast IPv6 addresses (ff00::/8)", () => {
expect(isPrivateOrLoopbackHost("[ff02::1]")).toBe(false);
expect(isPrivateOrLoopbackHost("[ff05::2]")).toBe(false);
expect(isPrivateOrLoopbackHost("[ff0e::1]")).toBe(false);
});
it("rejects public addresses", () => {
expect(isPrivateOrLoopbackHost("1.1.1.1")).toBe(false);
expect(isPrivateOrLoopbackHost("8.8.8.8")).toBe(false);
expect(isPrivateOrLoopbackHost("203.0.113.10")).toBe(false);
});
it("rejects empty/falsy input", () => {
expect(isPrivateOrLoopbackHost("")).toBe(false);
});
});
describe("isSecureWebSocketUrl", () => {
it("accepts secure websocket/loopback ws URLs and rejects unsafe inputs", () => {
it("defaults to loopback-only ws:// and rejects private/public remote ws://", () => {
const cases = [
// wss:// always accepted
{ input: "wss://127.0.0.1:18789", expected: true },
{ input: "wss://localhost:18789", expected: true },
{ input: "wss://remote.example.com:18789", expected: true },
{ input: "wss://192.168.1.100:18789", expected: true },
// ws:// loopback accepted
{ input: "ws://127.0.0.1:18789", expected: true },
{ input: "ws://localhost:18789", expected: true },
{ input: "ws://[::1]:18789", expected: true },
{ input: "ws://127.0.0.42:18789", expected: true },
{ input: "ws://remote.example.com:18789", expected: false },
{ input: "ws://192.168.1.100:18789", expected: false },
// ws:// private/public remote addresses rejected by default
{ input: "ws://10.0.0.5:18789", expected: false },
{ input: "ws://10.42.1.100:18789", expected: false },
{ input: "ws://172.16.0.1:18789", expected: false },
{ input: "ws://172.31.255.254:18789", expected: false },
{ input: "ws://192.168.1.100:18789", expected: false },
{ input: "ws://169.254.10.20:18789", expected: false },
{ input: "ws://100.64.0.1:18789", expected: false },
{ input: "ws://[fc00::1]:18789", expected: false },
{ input: "ws://[fd12:3456:789a::1]:18789", expected: false },
{ input: "ws://[fe80::1]:18789", expected: false },
{ input: "ws://[::]:18789", expected: false },
{ input: "ws://[ff02::1]:18789", expected: false },
// ws:// public addresses rejected
{ input: "ws://remote.example.com:18789", expected: false },
{ input: "ws://1.1.1.1:18789", expected: false },
{ input: "ws://8.8.8.8:18789", expected: false },
{ input: "ws://203.0.113.10:18789", expected: false },
// invalid URLs
{ input: "not-a-url", expected: false },
{ input: "", expected: false },
{ input: "http://127.0.0.1:18789", expected: false },
@@ -374,4 +447,32 @@ describe("isSecureWebSocketUrl", () => {
expect(isSecureWebSocketUrl(testCase.input), testCase.input).toBe(testCase.expected);
}
});
it("allows private ws:// only when opt-in is enabled", () => {
const allowedWhenOptedIn = [
"ws://10.0.0.5:18789",
"ws://172.16.0.1:18789",
"ws://192.168.1.100:18789",
"ws://100.64.0.1:18789",
"ws://169.254.10.20:18789",
"ws://[fc00::1]:18789",
"ws://[fe80::1]:18789",
];
for (const input of allowedWhenOptedIn) {
expect(isSecureWebSocketUrl(input, { allowPrivateWs: true }), input).toBe(true);
}
});
it("still rejects non-unicast IPv6 ws:// even when opt-in is enabled", () => {
const disallowedWhenOptedIn = [
"ws://[::]:18789",
"ws://[0:0::0]:18789",
"ws://[ff02::1]:18789",
];
for (const input of disallowedWhenOptedIn) {
expect(isSecureWebSocketUrl(input, { allowPrivateWs: true }), input).toBe(false);
}
});
});

View File

@@ -347,17 +347,57 @@ export function isLocalishHost(hostHeader?: string): boolean {
return isLoopbackHost(host) || host.endsWith(".ts.net");
}
/**
* Check if a hostname or IP refers to a private or loopback address.
* Handles the same hostname formats as isLoopbackHost, but also accepts
* RFC 1918, link-local, CGNAT, and IPv6 ULA/link-local addresses.
*/
export function isPrivateOrLoopbackHost(host: string): boolean {
if (!host) {
return false;
}
const h = host.trim().toLowerCase();
if (h === "localhost") {
return true;
}
// Handle bracketed IPv6 addresses like [::1]
const unbracket = h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
const normalized = normalizeIp(unbracket);
if (!normalized || !isPrivateOrLoopbackAddress(normalized)) {
return false;
}
// isPrivateOrLoopbackAddress reuses SSRF-blocking ranges for IPv6, which
// include unspecified (::) and multicast (ff00::/8). Exclude these —
// they are not private/loopback unicast endpoints. (Multicast is UDP-only
// so TCP/WebSocket connections would fail regardless.)
if (net.isIP(normalized) === 6) {
if (normalized.startsWith("ff")) {
return false;
}
if (normalized === "::") {
return false;
}
}
return true;
}
/**
* Security check for WebSocket URLs (CWE-319: Cleartext Transmission of Sensitive Information).
*
* Returns true if the URL is secure for transmitting data:
* - wss:// (TLS) is always secure
* - ws:// is only secure for loopback addresses (localhost, 127.x.x.x, ::1)
* - ws:// is secure only for loopback addresses by default
* - optional break-glass: private ws:// can be enabled for trusted networks
*
* All other ws:// URLs are considered insecure because both credentials
* AND chat/conversation data would be exposed to network interception.
*/
export function isSecureWebSocketUrl(url: string): boolean {
export function isSecureWebSocketUrl(
url: string,
opts?: {
allowPrivateWs?: boolean;
},
): boolean {
let parsed: URL;
try {
parsed = new URL(url);
@@ -373,6 +413,13 @@ export function isSecureWebSocketUrl(url: string): boolean {
return false;
}
// ws:// is only secure for loopback addresses
return isLoopbackHost(parsed.hostname);
// Default policy stays strict: loopback-only plaintext ws://.
if (isLoopbackHost(parsed.hostname)) {
return true;
}
// Optional break-glass for trusted private-network overlays.
if (opts?.allowPrivateWs) {
return isPrivateOrLoopbackHost(parsed.hostname);
}
return false;
}