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

@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
- Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
- Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.

View File

@@ -5,6 +5,7 @@ services:
HOME: /home/node
TERM: xterm-256color
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
@@ -51,6 +52,7 @@ services:
HOME: /home/node
TERM: xterm-256color
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
BROWSER: echo
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}

View File

@@ -177,6 +177,7 @@ export OPENCLAW_IMAGE="$IMAGE_NAME"
export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}"
export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}"
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
@@ -331,7 +332,8 @@ upsert_env "$ENV_FILE" \
OPENCLAW_IMAGE \
OPENCLAW_EXTRA_MOUNTS \
OPENCLAW_HOME_VOLUME \
OPENCLAW_DOCKER_APT_PACKAGES
OPENCLAW_DOCKER_APT_PACKAGES \
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
echo "==> Building Docker image: $IMAGE_NAME"

View File

@@ -23,9 +23,12 @@ Interactive onboarding wizard (local or remote Gateway setup).
openclaw onboard
openclaw onboard --flow quickstart
openclaw onboard --flow manual
openclaw onboard --mode remote --remote-url ws://gateway-host:18789
openclaw onboard --mode remote --remote-url wss://gateway-host:18789
```
For plaintext private-network `ws://` targets (trusted networks only), set
`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` in the onboarding process environment.
Non-interactive custom provider:
```bash

View File

@@ -2315,6 +2315,7 @@ See [Plugins](/tools/plugin).
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.

View File

@@ -133,6 +133,8 @@ Runbook: [macOS remote access](/platforms/mac/remote).
Short version: **keep the Gateway loopback-only** unless youre sure you need a bind.
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
- Plaintext `ws://` is loopback-only by default. For trusted private networks,
set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.

View File

@@ -691,6 +691,8 @@ do **not** protect local WS access by themselves.
Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*`
is unset.
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Plaintext `ws://` is loopback-only by default. For trusted private-network
paths, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.
Local device pairing:

View File

@@ -59,6 +59,8 @@ Optional env vars:
- `OPENCLAW_DOCKER_APT_PACKAGES` — install extra apt packages during build
- `OPENCLAW_EXTRA_MOUNTS` — add extra host bind mounts
- `OPENCLAW_HOME_VOLUME` — persist `/home/node` in a named volume
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` — break-glass: allow trusted private-network
`ws://` targets for CLI/onboarding client paths (default is loopback-only)
After it finishes:

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