diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ad5f8b1bc..1b1ef7ca654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Pairing/security: require private-IP or loopback hosts for cleartext mobile pairing, and stop treating `.local` or dotless hostnames as safe cleartext endpoints. (#70721) Thanks @vincentkoc. - Approvals/security: require explicit chat exec-approval enablement instead of auto-enabling approval clients just because approvers resolve from config or owner allowlists. (#70715) Thanks @vincentkoc. - Discord/security: keep native slash-command channel policy from bypassing configured owner or member restrictions, while preserving channel-policy fallback when no stricter access rule exists. (#70711) Thanks @vincentkoc. - Android/security: stop `ASK_OPENCLAW` intents from auto-sending injected prompts, so external app actions only prefill the draft instead of dispatching it immediately. (#70714) Thanks @vincentkoc. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt index c5f0936770d..d878b13bb05 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt @@ -63,8 +63,6 @@ internal fun isPrivateLanGatewayHost( } if (host.isEmpty()) return false if (isLoopbackGatewayHost(host, allowEmulatorBridgeAlias = allowEmulatorBridgeAlias)) return true - if (host.endsWith(".local")) return true - if (!host.contains('.') && !host.contains(':')) return true parseIpv4Address(host)?.let { ipv4 -> val first = ipv4[0].toInt() and 0xff diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index ca09101d136..57eefc32eec 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -240,9 +240,9 @@ class ConnectionManagerTest { } @Test - fun isPrivateLanGatewayHost_acceptsLanHostsButRejectsTailnetHosts() { + fun isPrivateLanGatewayHost_acceptsLanIpsButRejectsMdnsAndTailnetHosts() { assertTrue(isPrivateLanGatewayHost("192.168.1.20")) - assertTrue(isPrivateLanGatewayHost("gateway.local")) + assertFalse(isPrivateLanGatewayHost("gateway.local")) assertFalse(isPrivateLanGatewayHost("100.64.0.9")) assertFalse(isPrivateLanGatewayHost("gateway.tailnet.ts.net")) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index ca955969742..05f8d40a308 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -114,18 +114,10 @@ class GatewayConfigResolverTest { } @Test - fun parseGatewayEndpointAllowsMdnsCleartextWsUrls() { + fun parseGatewayEndpointRejectsMdnsCleartextWsUrls() { val parsed = parseGatewayEndpoint("ws://gateway.local:18789") - assertEquals( - GatewayEndpointConfig( - host = "gateway.local", - port = 18789, - tls = false, - displayUrl = "http://gateway.local:18789", - ), - parsed, - ) + assertNull(parsed) } @Test diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 71b62905a5c..2e13f01247a 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -434,21 +434,6 @@ describe("pairing setup code", () => { urlSource: "gateway.bind=custom", }, }, - { - name: "allows mdns hostname cleartext setup urls", - config: { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { mode: "token", token: "tok_123" }, - }, - } satisfies ResolveSetupConfig, - expected: { - authLabel: "token", - url: "ws://gateway.local:18789", - urlSource: "gateway.bind=custom", - }, - }, ] as const)("$name", async ({ config, options, expected }) => { await expectResolvedSetupSuccessCase({ config, @@ -469,6 +454,17 @@ describe("pairing setup code", () => { } satisfies ResolveSetupConfig, expectedError: "Tailscale and public mobile pairing require a secure gateway URL", }, + { + name: "rejects mdns hostname cleartext setup urls", + config: { + gateway: { + bind: "custom", + customBindHost: "gateway.local", + auth: { mode: "token", token: "tok_123" }, + }, + } satisfies ResolveSetupConfig, + expectedError: "private LAN IP address", + }, { name: "rejects tailnet bind remote ws setup urls for mobile pairing", config: { diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index baee70439fc..52c09b8278c 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -6,7 +6,6 @@ import { materializeGatewayAuthSecretRefs } from "../gateway/auth-config-utils.j import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; import { isLoopbackHost, isSecureWebSocketUrl } from "../gateway/net.js"; import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; -import { normalizeHostname } from "../infra/net/hostname.js"; import { pickMatchingExternalInterfaceAddress, safeNetworkInterfaces, @@ -75,20 +74,12 @@ function describeSecureMobilePairingFix(source?: string): string { return ( "Tailscale and public mobile pairing require a secure gateway URL (wss://) or Tailscale Serve/Funnel." + sourceNote + - " Fix: use a private LAN host/address, prefer gateway.tailscale.mode=serve, or set " + + " Fix: use a private LAN IP address, prefer gateway.tailscale.mode=serve, or set " + "gateway.remote.url / plugins.entries.device-pair.config.publicUrl to a wss:// URL. " + - "ws:// is only valid for localhost, private LAN, or the Android emulator." + "ws:// is only valid for localhost, private LAN IP addresses, or the Android emulator." ); } -function isPrivateLanHostname(host: string): boolean { - const normalized = normalizeHostname(host); - if (!normalized) { - return false; - } - return normalized.endsWith(".local") || (!normalized.includes(".") && !normalized.includes(":")); -} - function isPrivateLanIpHost(host: string): boolean { if (isRfc1918Ipv4Address(host)) { return true; @@ -111,12 +102,7 @@ function isPrivateLanIpHost(host: string): boolean { } function isMobilePairingCleartextAllowedHost(host: string): boolean { - return ( - isLoopbackHost(host) || - host === "10.0.2.2" || - isPrivateLanIpHost(host) || - isPrivateLanHostname(host) - ); + return isLoopbackHost(host) || host === "10.0.2.2" || isPrivateLanIpHost(host); } function validateMobilePairingUrl(url: string, source?: string): string | null {