fix(android): require private IP cleartext pairing

This commit is contained in:
Vincent Koc
2026-04-23 11:56:47 -07:00
parent a63939d295
commit cad102c3ca
6 changed files with 19 additions and 46 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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"))
}

View File

@@ -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

View File

@@ -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: {

View File

@@ -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 {