diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1ef7ca654..c5faa731e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Android/security: require loopback-only cleartext gateway connections on Android manual and scanned routes, so private-LAN and link-local `ws://` endpoints now fail closed unless TLS is enabled. (#70722) Thanks @vincentkoc. - 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. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 6355fff1511..f155fce9e64 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -7,7 +7,7 @@ import ai.openclaw.app.gateway.GatewayClientInfo import ai.openclaw.app.gateway.GatewayConnectOptions import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewayTlsParams -import ai.openclaw.app.gateway.isPrivateLanGatewayHost +import ai.openclaw.app.gateway.isLoopbackGatewayHost import ai.openclaw.app.LocationMode import ai.openclaw.app.VoiceWakeMode @@ -34,7 +34,7 @@ class ConnectionManager( val stableId = endpoint.stableId val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() } val isManual = stableId.startsWith("manual|") - val cleartextAllowedHost = isPrivateLanGatewayHost(endpoint.host) + val cleartextAllowedHost = isLoopbackGatewayHost(endpoint.host) if (isManual) { if (!manualTlsEnabled && cleartextAllowedHost) return null diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index 95c5b53ddf4..9a66aad5835 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -1,6 +1,6 @@ package ai.openclaw.app.ui -import ai.openclaw.app.gateway.isPrivateLanGatewayHost +import ai.openclaw.app.gateway.isLoopbackGatewayHost import java.util.Base64 import java.util.Locale import java.net.URI @@ -56,9 +56,9 @@ internal data class GatewayScannedSetupCodeResult( private val gatewaySetupJson = Json { ignoreUnknownKeys = true } private const val remoteGatewaySecurityRule = - "Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed for private LAN, localhost, and the Android emulator." + "Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed only for localhost and the Android emulator." private const val remoteGatewaySecurityFix = - "Use a private LAN host/address, or enable Tailscale Serve / expose a wss:// gateway URL." + "Use localhost/the Android emulator, or enable Tailscale Serve / expose a wss:// gateway URL." internal fun resolveGatewayConnectConfig( useSetupCode: Boolean, @@ -143,7 +143,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { "wss", "https" -> true else -> true } - if (!tls && !isPrivateLanGatewayHost(host)) { + if (!tls && !isLoopbackGatewayHost(host)) { return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL) } val defaultPort = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt index 5735a7fb99c..ee0cfdd8d03 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt @@ -49,7 +49,7 @@ internal fun buildGatewayDiagnosticsReport( Please: - pick one route only: same machine, same LAN, Tailscale, or public URL - classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down - - remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; private LAN ws:// is still allowed + - remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; ws:// is loopback-only - quote the exact app status/error below - tell me whether `openclaw devices list` should show a pending pairing request - if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status` 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 57eefc32eec..11aa01098df 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 @@ -108,7 +108,7 @@ class ConnectionManagerTest { } @Test - fun resolveTlsParamsForEndpoint_manualPrivateLanCanStayCleartextWhenToggleIsOff() { + fun resolveTlsParamsForEndpoint_manualPrivateLanForcesTlsWhenToggleIsOff() { val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789) val params = @@ -118,7 +118,9 @@ class ConnectionManagerTest { manualTlsEnabled = false, ) - assertNull(params) + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) } @Test @@ -146,7 +148,7 @@ class ConnectionManagerTest { } @Test - fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsCanStayCleartext() { + fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsStillRequiresTls() { val endpoint = GatewayEndpoint( stableId = "_openclaw-gw._tcp.|local.|Test", @@ -164,7 +166,9 @@ class ConnectionManagerTest { manualTlsEnabled = false, ) - assertNull(params) + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) } @Test 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 05f8d40a308..6ef1081eb6b 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 @@ -99,18 +99,9 @@ class GatewayConfigResolverTest { } @Test - fun parseGatewayEndpointAllowsPrivateLanCleartextWsUrls() { + fun parseGatewayEndpointRejectsPrivateLanCleartextWsUrls() { val parsed = parseGatewayEndpoint("ws://192.168.1.20:18789") - - assertEquals( - GatewayEndpointConfig( - host = "192.168.1.20", - port = 18789, - tls = false, - displayUrl = "http://192.168.1.20:18789", - ), - parsed, - ) + assertNull(parsed) } @Test @@ -155,13 +146,9 @@ class GatewayConfigResolverTest { } @Test - fun parseGatewayEndpointAllowsLinkLocalIpv6ZoneCleartextWsUrls() { + fun parseGatewayEndpointRejectsLinkLocalIpv6ZoneCleartextWsUrls() { val parsed = parseGatewayEndpoint("ws://[fe80::1%25eth0]") - - assertEquals("fe80::1%25eth0", parsed?.host) - assertEquals(18789, parsed?.port) - assertEquals(false, parsed?.tls) - assertEquals("http://[fe80::1%25eth0]:18789", parsed?.displayUrl) + assertNull(parsed) } @Test @@ -282,19 +269,10 @@ class GatewayConfigResolverTest { } @Test - fun parseGatewayEndpointResultAcceptsLanCleartextGateway() { + fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() { val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789") - - assertEquals( - GatewayEndpointConfig( - host = "192.168.1.20", - port = 18789, - tls = false, - displayUrl = "http://192.168.1.20:18789", - ), - parsed.config, - ) - assertNull(parsed.error) + assertNull(parsed.config) + assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error) } @Test @@ -435,7 +413,7 @@ class GatewayConfigResolverTest { } @Test - fun resolveGatewayConnectConfigAllowsPrivateLanManualCleartextEndpoint() { + fun resolveGatewayConnectConfigRejectsPrivateLanManualCleartextEndpoint() { val resolved = resolveGatewayConnectConfig( useSetupCode = false, @@ -451,9 +429,7 @@ class GatewayConfigResolverTest { fallbackPassword = "", ) - assertEquals("192.168.31.100", resolved?.host) - assertEquals(18789, resolved?.port) - assertEquals(false, resolved?.tls) + assertNull(resolved) } @Test