Skip to content

Commit 4495114

Browse files
dashedvincentkoc
andauthored
fix(gateway): allow ws:// to private network addresses (openclaw#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>
1 parent d76b224 commit 4495114

16 files changed

Lines changed: 272 additions & 14 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
5252
- 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.
5353
- 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.
5454
- 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.
55+
- 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.
5556
- 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.
5657
- 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.
5758
- 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.

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ services:
55
HOME: /home/node
66
TERM: xterm-256color
77
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
8+
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
89
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
910
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
1011
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
@@ -51,6 +52,7 @@ services:
5152
HOME: /home/node
5253
TERM: xterm-256color
5354
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
55+
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
5456
BROWSER: echo
5557
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
5658
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}

docker-setup.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export OPENCLAW_IMAGE="$IMAGE_NAME"
177177
export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}"
178178
export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
179179
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
180+
export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}"
180181

181182
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
182183
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
@@ -331,7 +332,8 @@ upsert_env "$ENV_FILE" \
331332
OPENCLAW_IMAGE \
332333
OPENCLAW_EXTRA_MOUNTS \
333334
OPENCLAW_HOME_VOLUME \
334-
OPENCLAW_DOCKER_APT_PACKAGES
335+
OPENCLAW_DOCKER_APT_PACKAGES \
336+
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS
335337

336338
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
337339
echo "==> Building Docker image: $IMAGE_NAME"

docs/cli/onboard.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ Interactive onboarding wizard (local or remote Gateway setup).
2323
openclaw onboard
2424
openclaw onboard --flow quickstart
2525
openclaw onboard --flow manual
26-
openclaw onboard --mode remote --remote-url ws://gateway-host:18789
26+
openclaw onboard --mode remote --remote-url wss://gateway-host:18789
2727
```
2828

29+
For plaintext private-network `ws://` targets (trusted networks only), set
30+
`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` in the onboarding process environment.
31+
2932
Non-interactive custom provider:
3033

3134
```bash

docs/gateway/configuration-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2315,6 +2315,7 @@ See [Plugins](/tools/plugin).
23152315
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
23162316
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
23172317
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
2318+
- `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.
23182319
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
23192320
- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
23202321
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.

docs/gateway/remote.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ Runbook: [macOS remote access](/platforms/mac/remote).
133133
Short version: **keep the Gateway loopback-only** unless you’re sure you need a bind.
134134

135135
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
136+
- Plaintext `ws://` is loopback-only by default. For trusted private networks,
137+
set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.
136138
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
137139
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
138140
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.

docs/gateway/security/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,8 @@ do **not** protect local WS access by themselves.
691691
Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*`
692692
is unset.
693693
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
694+
Plaintext `ws://` is loopback-only by default. For trusted private-network
695+
paths, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.
694696

695697
Local device pairing:
696698

docs/install/docker.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ Optional env vars:
5959
- `OPENCLAW_DOCKER_APT_PACKAGES` — install extra apt packages during build
6060
- `OPENCLAW_EXTRA_MOUNTS` — add extra host bind mounts
6161
- `OPENCLAW_HOME_VOLUME` — persist `/home/node` in a named volume
62+
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` — break-glass: allow trusted private-network
63+
`ws://` targets for CLI/onboarding client paths (default is loopback-only)
6264

6365
After it finishes:
6466

src/commands/onboard-remote.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../config/config.js";
33
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
4+
import { captureEnv } from "../test-utils/env.js";
45
import type { WizardPrompter } from "../wizard/prompts.js";
56
import { createWizardPrompter } from "./test-wizard-helpers.js";
67

@@ -27,8 +28,11 @@ function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
2728
}
2829

2930
describe("promptRemoteGatewayConfig", () => {
31+
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
32+
3033
beforeEach(() => {
3134
vi.clearAllMocks();
35+
envSnapshot.restore();
3236
detectBinary.mockResolvedValue(false);
3337
discoverGatewayBeacons.mockResolvedValue([]);
3438
resolveWideAreaDiscoveryDomain.mockReturnValue(undefined);
@@ -88,9 +92,12 @@ describe("promptRemoteGatewayConfig", () => {
8892
);
8993
});
9094

91-
it("validates insecure ws:// remote URLs and allows loopback ws://", async () => {
95+
it("validates insecure ws:// remote URLs and allows only loopback ws:// by default", async () => {
9296
const text: WizardPrompter["text"] = vi.fn(async (params) => {
9397
if (params.message === "Gateway WebSocket URL") {
98+
// ws:// to public IPs is rejected
99+
expect(params.validate?.("ws://203.0.113.10:18789")).toContain("Use wss://");
100+
// ws:// to private IPs remains blocked by default
94101
expect(params.validate?.("ws://10.0.0.8:18789")).toContain("Use wss://");
95102
expect(params.validate?.("ws://127.0.0.1:18789")).toBeUndefined();
96103
expect(params.validate?.("wss://remote.example.com:18789")).toBeUndefined();
@@ -119,4 +126,34 @@ describe("promptRemoteGatewayConfig", () => {
119126
expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789");
120127
expect(next.gateway?.remote?.token).toBeUndefined();
121128
});
129+
130+
it("allows private ws:// only when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", async () => {
131+
process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1";
132+
133+
const text: WizardPrompter["text"] = vi.fn(async (params) => {
134+
if (params.message === "Gateway WebSocket URL") {
135+
expect(params.validate?.("ws://10.0.0.8:18789")).toBeUndefined();
136+
return "ws://10.0.0.8:18789";
137+
}
138+
return "";
139+
}) as WizardPrompter["text"];
140+
141+
const select: WizardPrompter["select"] = vi.fn(async (params) => {
142+
if (params.message === "Gateway auth") {
143+
return "off" as never;
144+
}
145+
return (params.options[0]?.value ?? "") as never;
146+
});
147+
148+
const cfg = {} as OpenClawConfig;
149+
const prompter = createPrompter({
150+
confirm: vi.fn(async () => false),
151+
select,
152+
text,
153+
});
154+
155+
const next = await promptRemoteGatewayConfig(cfg, prompter);
156+
157+
expect(next.gateway?.remote?.url).toBe("ws://10.0.0.8:18789");
158+
});
122159
});

src/commands/onboard-remote.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,15 @@ function validateGatewayWebSocketUrl(value: string): string | undefined {
3535
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) {
3636
return "URL must start with ws:// or wss://";
3737
}
38-
if (!isSecureWebSocketUrl(trimmed)) {
39-
return "Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel.";
38+
if (
39+
!isSecureWebSocketUrl(trimmed, {
40+
allowPrivateWs: process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1",
41+
})
42+
) {
43+
return (
44+
"Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel. " +
45+
"Break-glass: OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 for trusted private networks."
46+
);
4047
}
4148
return undefined;
4249
}

0 commit comments

Comments
 (0)