diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e6f5bd04c..0b03bdde385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Exec/Windows: reject malformed drive-less rooted executable paths like `:\Users\...` so approval and allowlist candidate resolution no longer treat them as cwd-relative commands. (#58040) Thanks @SnowSky1. - Exec/preflight: fail closed on complex interpreter invocations that would otherwise skip script-content validation, and correctly inspect quoted script paths before host execution. Thanks @pgondhi987. - Exec/Windows: include Windows-compatible env override keys like `ProgramFiles(x86)` in system-run approval binding so changed approved values are rejected instead of silently passing unbound. (#59182) Thanks @pgondhi987. +- Android/gateway: require TLS for non-loopback remote gateway endpoints while still allowing local loopback and emulator cleartext setup flows. (#58475) Thanks @eleqtrizit. - Exec/Windows: hide transient console windows for `runExec` and `runCommandWithTimeout` child-process launches, matching other Windows exec paths and stopping visible shell flashes during tool runs. (#59466) Thanks @lawrence3699. ## 2026.4.2-beta.1 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 9ffe500f7d2..271bf27b424 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -44,6 +44,7 @@ import java.util.concurrent.atomic.AtomicLong class NodeRuntime( context: Context, val prefs: SecurePrefs = SecurePrefs(context.applicationContext), + private val tlsFingerprintProbe: suspend (String, Int) -> String? = ::probeGatewayTlsFingerprint, ) { data class GatewayConnectAuth( val token: String?, @@ -189,6 +190,7 @@ class NodeRuntime( data class GatewayTrustPrompt( val endpoint: GatewayEndpoint, val fingerprintSha256: String, + val auth: GatewayConnectAuth, ) private val _isConnected = MutableStateFlow(false) @@ -828,17 +830,21 @@ class NodeRuntime( } } - fun connect(endpoint: GatewayEndpoint) { + private fun beginConnect( + endpoint: GatewayEndpoint, + auth: GatewayConnectAuth, + ) { val tls = connectionManager.resolveTlsParams(endpoint) if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) { // First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect. _statusText.value = "Verify gateway TLS fingerprint…" scope.launch { - val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run { + val fp = tlsFingerprintProbe(endpoint.host, endpoint.port) ?: run { _statusText.value = "Failed: can't read TLS fingerprint" return@launch } - _pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp) + _pendingGatewayTrust.value = + GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp, auth = auth) } return } @@ -847,18 +853,18 @@ class NodeRuntime( operatorStatusText = "Connecting…" nodeStatusText = "Connecting…" updateStatus() - connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth()) + connectWithAuth(endpoint = endpoint, auth = auth) + } + + fun connect(endpoint: GatewayEndpoint) { + beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth()) } fun connect( endpoint: GatewayEndpoint, auth: GatewayConnectAuth, ) { - connectedEndpoint = endpoint - operatorStatusText = "Connecting…" - nodeStatusText = "Connecting…" - updateStatus() - connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(auth)) + beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(auth)) } internal fun resolveGatewayConnectAuth(explicitAuth: GatewayConnectAuth? = null): GatewayConnectAuth { @@ -874,7 +880,7 @@ class NodeRuntime( val prompt = _pendingGatewayTrust.value ?: return _pendingGatewayTrust.value = null prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256) - connect(prompt.endpoint) + beginConnect(endpoint = prompt.endpoint, auth = prompt.auth) } fun declineGatewayTrustPrompt() { 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 new file mode 100644 index 00000000000..3afea447193 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt @@ -0,0 +1,80 @@ +package ai.openclaw.app.gateway + +import android.os.Build +import java.net.InetAddress +import java.util.Locale + +internal fun isLoopbackGatewayHost( + rawHost: String?, + allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(), +): Boolean { + var host = + rawHost + ?.trim() + ?.lowercase(Locale.US) + ?.trim('[', ']') + .orEmpty() + if (host.endsWith(".")) { + host = host.dropLast(1) + } + val zoneIndex = host.indexOf('%') + if (zoneIndex >= 0) { + host = host.substring(0, zoneIndex) + } + if (host.isEmpty()) return false + if (host == "localhost") return true + if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true + + parseIpv4Address(host)?.let { ipv4 -> + return ipv4.first() == 127.toByte() + } + if (!host.contains(':') || !host.all(::isIpv6LiteralChar)) return false + + val address = runCatching { InetAddress.getByName(host) }.getOrNull()?.address ?: return false + if (address.size == 4) { + return address[0] == 127.toByte() + } + if (address.size != 16) return false + // `::1` is 15 zero bytes followed by `0x01`. + val isIpv6Loopback = address.copyOfRange(0, 15).all { it == 0.toByte() } && address[15] == 1.toByte() + if (isIpv6Loopback) return true + + val isMappedIpv4 = + address.copyOfRange(0, 10).all { it == 0.toByte() } && + address[10] == 0xFF.toByte() && + address[11] == 0xFF.toByte() + return isMappedIpv4 && address[12] == 127.toByte() +} + +private fun isAndroidEmulatorRuntime(): Boolean { + val fingerprint = Build.FINGERPRINT?.lowercase(Locale.US).orEmpty() + val model = Build.MODEL?.lowercase(Locale.US).orEmpty() + val manufacturer = Build.MANUFACTURER?.lowercase(Locale.US).orEmpty() + val brand = Build.BRAND?.lowercase(Locale.US).orEmpty() + val device = Build.DEVICE?.lowercase(Locale.US).orEmpty() + val product = Build.PRODUCT?.lowercase(Locale.US).orEmpty() + + return fingerprint.contains("generic") || + fingerprint.contains("robolectric") || + model.contains("emulator") || + model.contains("sdk_gphone") || + manufacturer.contains("genymotion") || + (brand.contains("generic") && device.contains("generic")) || + product.contains("sdk_gphone") || + product.contains("emulator") || + product.contains("simulator") +} + +private fun parseIpv4Address(host: String): ByteArray? { + val parts = host.split('.') + if (parts.size != 4) return null + val bytes = ByteArray(4) + for ((index, part) in parts.withIndex()) { + val value = part.toIntOrNull() ?: return null + if (value !in 0..255) return null + bytes[index] = value.toByte() + } + return bytes +} + +private fun isIpv6LiteralChar(char: Char): Boolean = char in '0'..'9' || char in 'a'..'f' || char == ':' || char == '.' diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index fff8ec843c9..ccca391b0be 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -268,16 +268,10 @@ class GatewaySession( private var socket: WebSocket? = null private val loggerTag = "OpenClawGateway" - val remoteAddress: String = - if (endpoint.host.contains(":")) { - "[${endpoint.host}]:${endpoint.port}" - } else { - "${endpoint.host}:${endpoint.port}" - } + val remoteAddress: String = formatGatewayAuthority(endpoint.host, endpoint.port) suspend fun connect() { - val scheme = if (tls != null) "wss" else "ws" - val url = "$scheme://${endpoint.host}:${endpoint.port}" + val url = buildGatewayWebSocketUrl(endpoint.host, endpoint.port, tls != null) val request = Request.Builder().url(url).build() socket = client.newWebSocket(request, Listener()) try { @@ -752,7 +746,7 @@ class GatewaySession( // If raw URL is a non-loopback address and this connection uses TLS, // normalize scheme/port to the endpoint we actually connected to. - if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) { + if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackGatewayHost(host)) { val needsTlsRewrite = isTlsConnection && ( @@ -781,7 +775,7 @@ class GatewaySession( private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String { val loweredScheme = scheme.lowercase() - val formattedHost = if (host.contains(":")) "[${host}]" else host + val formattedHost = formatGatewayAuthorityHost(host) val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port" return "$loweredScheme://$formattedHost$portSuffix$suffix" } @@ -794,15 +788,6 @@ class GatewaySession( return "$path$query$fragment" } - private fun isLoopbackHost(raw: String?): Boolean { - val host = raw?.trim()?.lowercase().orEmpty() - if (host.isEmpty()) return false - if (host == "localhost") return true - if (host == "::1") return true - if (host == "0.0.0.0" || host == "::") return true - return host.startsWith("127.") - } - private fun selectConnectAuth( endpoint: GatewayEndpoint, tls: GatewayTlsParams?, @@ -891,13 +876,27 @@ class GatewaySession( endpoint: GatewayEndpoint, tls: GatewayTlsParams?, ): Boolean { - if (isLoopbackHost(endpoint.host)) { + if (isLoopbackGatewayHost(endpoint.host)) { return true } return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true } } +internal fun buildGatewayWebSocketUrl(host: String, port: Int, useTls: Boolean): String { + val scheme = if (useTls) "wss" else "ws" + return "$scheme://${formatGatewayAuthority(host, port)}" +} + +internal fun formatGatewayAuthority(host: String, port: Int): String { + return "${formatGatewayAuthorityHost(host)}:$port" +} + +private fun formatGatewayAuthorityHost(host: String): String { + val normalizedHost = host.trim().trim('[', ']') + return if (normalizedHost.contains(":")) "[${normalizedHost}]" else normalizedHost +} + private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject private fun JsonElement?.asStringOrNull(): String? = 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 09bc09714cf..4274d26ecfd 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,6 +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.isLoopbackGatewayHost import ai.openclaw.app.LocationMode import ai.openclaw.app.VoiceWakeMode @@ -33,9 +34,10 @@ class ConnectionManager( val stableId = endpoint.stableId val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() } val isManual = stableId.startsWith("manual|") + val isLoopback = isLoopbackGatewayHost(endpoint.host) if (isManual) { - if (!manualTlsEnabled) return null + if (!manualTlsEnabled && isLoopback) return null if (!stored.isNullOrBlank()) { return GatewayTlsParams( required = true, @@ -73,6 +75,15 @@ class ConnectionManager( ) } + if (!isLoopback) { + return GatewayTlsParams( + required = true, + expectedFingerprint = null, + allowTOFU = false, + stableId = stableId, + ) + } + 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 15fbfa4f347..169223a34da 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,5 +1,6 @@ package ai.openclaw.app.ui +import ai.openclaw.app.gateway.isLoopbackGatewayHost import java.util.Base64 import java.util.Locale import java.net.URI @@ -101,7 +102,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { val normalized = if (raw.contains("://")) raw else "https://$raw" val uri = runCatching { URI(normalized) }.getOrNull() ?: return null - val host = uri.host?.trim().orEmpty() + val host = uri.host?.trim()?.trim('[', ']').orEmpty() if (host.isEmpty()) return null val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() @@ -111,6 +112,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { "wss", "https" -> true else -> true } + if (!tls && !isLoopbackGatewayHost(host)) return null val defaultPort = when (scheme) { "wss", "https" -> 443 @@ -124,11 +126,12 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { else -> 443 } val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort + val displayHost = if (host.contains(":")) "[$host]" else host val displayUrl = if (port == displayPort && defaultPort == displayPort) { - "${if (tls) "https" else "http"}://$host" + "${if (tls) "https" else "http"}://$displayHost" } else { - "${if (tls) "https" else "http"}://$host:$port" + "${if (tls) "https" else "http"}://$displayHost:$port" } return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl) @@ -163,7 +166,8 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { internal fun resolveScannedSetupCode(rawInput: String): String? { val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null - return setupCode.takeIf { decodeGatewaySetupCode(it) != null } + val decoded = decodeGatewaySetupCode(setupCode) ?: return null + return setupCode.takeIf { parseGatewayEndpoint(decoded.url) != null } } internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 1a761aa6cab..5547254b446 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -1,5 +1,8 @@ package ai.openclaw.app +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -9,6 +12,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import java.lang.reflect.Field import java.util.UUID @RunWith(RobolectricTestRunner::class) @@ -55,4 +59,71 @@ class GatewayBootstrapAuthTest { assertEquals("setup-bootstrap-token", auth.bootstrapToken) assertNull(auth.password) } + + @Test + fun acceptGatewayTrustPrompt_preservesExplicitSetupAuth() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + prefs.setGatewayToken("stale-shared-token") + prefs.setGatewayBootstrapToken("") + prefs.setGatewayPassword("stale-password") + val runtime = + NodeRuntime( + app, + prefs, + tlsFingerprintProbe = { _, _ -> "fp-1" }, + ) + val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) + val explicitAuth = + NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = "setup-bootstrap-token", + password = null, + ) + + runtime.connect(endpoint, explicitAuth) + val prompt = waitForGatewayTrustPrompt(runtime) + assertEquals("setup-bootstrap-token", prompt.auth.bootstrapToken) + + runtime.acceptGatewayTrustPrompt() + + assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId)) + assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession")) + assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession")) + } + + private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt { + repeat(50) { + runtime.pendingGatewayTrust.value?.let { return it } + Thread.sleep(10) + } + error("Expected pending gateway trust prompt") + } + + private fun desiredBootstrapToken(runtime: NodeRuntime, sessionFieldName: String): String? { + val session = readField(runtime, sessionFieldName) + val desired = readField(session, "desired") ?: return null + return readField(desired, "bootstrapToken") + } + + private fun readField(target: Any, name: String): T { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + val field: Field = type.getDeclaredField(name) + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + return field.get(target) as T + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt index 043d029d367..0f7b072722b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt @@ -4,6 +4,23 @@ import org.junit.Assert.assertEquals import org.junit.Test class GatewaySessionInvokeTimeoutTest { + @Test + fun formatGatewayAuthority_bracketsIpv6Hosts() { + assertEquals("[::1]:18789", formatGatewayAuthority("::1", 18_789)) + } + + @Test + fun buildGatewayWebSocketUrl_bracketsIpv6Hosts() { + assertEquals("ws://[::1]:18789", buildGatewayWebSocketUrl("::1", 18_789, useTls = false)) + assertEquals("wss://[::1]:443", buildGatewayWebSocketUrl("::1", 443, useTls = true)) + } + + @Test + fun buildGatewayWebSocketUrl_normalizesPersistedBracketedIpv6Hosts() { + assertEquals("ws://[::1]:18789", buildGatewayWebSocketUrl("[::1]", 18_789, useTls = false)) + assertEquals("wss://[::1]:443", buildGatewayWebSocketUrl("[::1]", 443, useTls = true)) + } + @Test fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() { assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null)) 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 0909c1584aa..60c66cf7163 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 @@ -10,6 +10,7 @@ import ai.openclaw.app.protocol.OpenClawLocationCommand import ai.openclaw.app.protocol.OpenClawMotionCommand import ai.openclaw.app.protocol.OpenClawSmsCommand import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.isLoopbackGatewayHost import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -69,7 +70,7 @@ class ConnectionManagerTest { @Test fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() { - val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443) + val endpoint = GatewayEndpoint.manual(host = "127.0.0.1", port = 443) val off = ConnectionManager.resolveTlsParamsForEndpoint( @@ -89,6 +90,234 @@ class ConnectionManagerTest { assertEquals(false, on?.allowTOFU) } + @Test + fun resolveTlsParamsForEndpoint_manualNonLoopbackForcesTlsWhenToggleIsOff() { + val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryNonLoopbackWithoutHintsStillRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "10.0.0.2", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryLoopbackWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "127.0.0.1", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryLocalhostWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "localhost", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryAndroidEmulatorWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "10.0.2.2", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun isLoopbackGatewayHost_onlyTreatsEmulatorBridgeAsLocalWhenAllowed() { + assertTrue(isLoopbackGatewayHost("10.0.2.2", allowEmulatorBridgeAlias = true)) + assertFalse(isLoopbackGatewayHost("10.0.2.2", allowEmulatorBridgeAlias = false)) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryIpv6LoopbackWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "::1", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryMappedIpv4LoopbackWithoutHintsCanStayCleartext() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "::ffff:127.0.0.1", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryNonLoopbackIpv6WithoutHintsRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "2001:db8::1", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryUnspecifiedIpv4WithoutHintsRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "0.0.0.0", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_discoveryUnspecifiedIpv6WithoutHintsRequiresTls() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "::", + port = 18789, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertEquals(true, params?.required) + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + @Test fun buildNodeConnectOptions_advertisesRequestableSmsSearchWithoutSmsCapability() { val options = 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 34764625e71..9f58b2c57aa 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 @@ -25,18 +25,10 @@ class GatewayConfigResolverTest { } @Test - fun parseGatewayEndpointUsesDefaultCleartextPortForBareWsUrls() { + fun parseGatewayEndpointRejectsNonLoopbackCleartextWsUrls() { val parsed = parseGatewayEndpoint("ws://gateway.example") - assertEquals( - GatewayEndpointConfig( - host = "gateway.example", - port = 18789, - tls = false, - displayUrl = "http://gateway.example:18789", - ), - parsed, - ) + assertNull(parsed) } @Test @@ -55,30 +47,115 @@ class GatewayConfigResolverTest { } @Test - fun parseGatewayEndpointKeepsExplicitNonDefaultPortInDisplayUrl() { - val parsed = parseGatewayEndpoint("http://gateway.example:8080") + fun parseGatewayEndpointAllowsLoopbackCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://127.0.0.1") assertEquals( GatewayEndpointConfig( - host = "gateway.example", - port = 8080, + host = "127.0.0.1", + port = 18789, tls = false, - displayUrl = "http://gateway.example:8080", + displayUrl = "http://127.0.0.1:18789", ), parsed, ) } @Test - fun parseGatewayEndpointKeepsExplicitCleartextPort80InDisplayUrl() { - val parsed = parseGatewayEndpoint("http://gateway.example:80") + fun parseGatewayEndpointAllowsLocalhostCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://localhost:18789") assertEquals( GatewayEndpointConfig( - host = "gateway.example", + host = "localhost", + port = 18789, + tls = false, + displayUrl = "http://localhost:18789", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointAllowsAndroidEmulatorCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://10.0.2.2:18789") + + assertEquals( + GatewayEndpointConfig( + host = "10.0.2.2", + port = 18789, + tls = false, + displayUrl = "http://10.0.2.2:18789", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointAllowsIpv6LoopbackCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://[::1]") + + assertEquals("::1", parsed?.host) + assertEquals(18789, parsed?.port) + assertEquals(false, parsed?.tls) + assertEquals("http://[::1]:18789", parsed?.displayUrl) + } + + @Test + fun parseGatewayEndpointAllowsIpv4MappedIpv6LoopbackCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://[::ffff:127.0.0.1]") + + assertEquals("::ffff:127.0.0.1", parsed?.host) + assertEquals(18789, parsed?.port) + assertEquals(false, parsed?.tls) + assertEquals("http://[::ffff:127.0.0.1]:18789", parsed?.displayUrl) + } + + @Test + fun parseGatewayEndpointRejectsCleartextLoopbackPrefixBypassHost() { + val parsed = parseGatewayEndpoint("http://127.attacker.example:80") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointRejectsNonLoopbackIpv6CleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://[2001:db8::1]") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointRejectsLinkLocalIpv6ZoneCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://[fe80::1%25eth0]") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointRejectsUnspecifiedIpv4CleartextHttpUrls() { + val parsed = parseGatewayEndpoint("http://0.0.0.0:80") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointRejectsUnspecifiedIpv6CleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://[::]") + + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointAllowsLoopbackCleartextHttpUrls() { + val parsed = parseGatewayEndpoint("http://localhost:80") + + assertEquals( + GatewayEndpointConfig( + host = "localhost", port = 80, tls = false, - displayUrl = "http://gateway.example:80", + displayUrl = "http://localhost:80", ), parsed, ) @@ -133,6 +210,16 @@ class GatewayConfigResolverTest { assertNull(resolved) } + @Test + fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() { + val setupCode = + encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = resolveScannedSetupCode(setupCode) + + assertNull(resolved) + } + @Test fun decodeGatewaySetupCodeParsesBootstrapToken() { val setupCode = @@ -208,10 +295,10 @@ class GatewayConfigResolverTest { resolveGatewayConnectConfig( useSetupCode = false, setupCode = "", - savedManualHost = "192.168.31.100", + savedManualHost = "127.0.0.1", savedManualPort = "18789", savedManualTls = false, - manualHostInput = "192.168.31.100", + manualHostInput = "127.0.0.1", manualPortInput = "18789", manualTlsInput = false, fallbackBootstrapToken = "bootstrap-1", @@ -219,7 +306,7 @@ class GatewayConfigResolverTest { fallbackPassword = "", ) - assertEquals("192.168.31.100", resolved?.host) + assertEquals("127.0.0.1", resolved?.host) assertEquals(18789, resolved?.port) assertEquals(false, resolved?.tls) assertEquals("bootstrap-1", resolved?.bootstrapToken) @@ -233,10 +320,10 @@ class GatewayConfigResolverTest { resolveGatewayConnectConfig( useSetupCode = false, setupCode = "", - savedManualHost = "192.168.31.100", + savedManualHost = "127.0.0.1", savedManualPort = "18789", savedManualTls = false, - manualHostInput = "192.168.31.100", + manualHostInput = "127.0.0.1", manualPortInput = "18789", manualTlsInput = false, fallbackBootstrapToken = "bootstrap-1", @@ -255,10 +342,10 @@ class GatewayConfigResolverTest { resolveGatewayConnectConfig( useSetupCode = false, setupCode = "", - savedManualHost = "192.168.31.100", + savedManualHost = "127.0.0.1", savedManualPort = "18789", savedManualTls = false, - manualHostInput = "192.168.31.101", + manualHostInput = "127.0.0.2", manualPortInput = "18789", manualTlsInput = false, fallbackBootstrapToken = "bootstrap-1", @@ -267,7 +354,27 @@ class GatewayConfigResolverTest { ) assertEquals("", resolved?.bootstrapToken) - assertEquals("192.168.31.101", resolved?.host) + assertEquals("127.0.0.2", resolved?.host) + } + + @Test + fun resolveGatewayConnectConfigRejectsNonLoopbackManualCleartextEndpoint() { + val resolved = + resolveGatewayConnectConfig( + useSetupCode = false, + setupCode = "", + savedManualHost = "", + savedManualPort = "", + savedManualTls = false, + manualHostInput = "192.168.31.100", + manualPortInput = "18789", + manualTlsInput = false, + fallbackBootstrapToken = "bootstrap-1", + fallbackToken = "", + fallbackPassword = "", + ) + + assertNull(resolved) } private fun encodeSetupCode(payloadJson: String): String {