mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -133,6 +133,8 @@ Runbook: [macOS remote access](/platforms/mac/remote).
|
||||
Short version: **keep the Gateway loopback-only** unless you’re 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user