Medium severity5.7NVD Advisory· Published Apr 21, 2026· Updated Apr 24, 2026
CVE-2026-40045
CVE-2026-40045
Description
OpenClaw before 2026.4.2 accepts non-loopback cleartext ws:// gateway endpoints and transmits stored gateway credentials over unencrypted connections. Attackers can forge discovery results or craft setup codes to redirect clients to malicious endpoints, disclosing plaintext gateway credentials.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.2 | 2026.4.2 |
Affected products
1Patches
1a941a4fef9bcfix(android): require TLS for remote gateway endpoints (#58475)
10 files changed · +586 −61
apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt+80 −0 added@@ -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 == '.'
apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt+19 −20 modified@@ -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? =
apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt+12 −1 modified@@ -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 } }
apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt+16 −10 modified@@ -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() {
apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt+8 −4 modified@@ -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? {
apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt+71 −0 modified@@ -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<GatewaySession>(runtime, sessionFieldName) + val desired = readField<Any?>(session, "desired") ?: return null + return readField(desired, "bootstrapToken") + } + + private fun <T> 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}") + } }
apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt+17 −0 modified@@ -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))
apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt+230 −1 modified@@ -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 =
apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt+132 −25 modified@@ -25,60 +25,137 @@ class GatewayConfigResolverTest { } @Test - fun parseGatewayEndpointUsesDefaultCleartextPortForBareWsUrls() { + fun parseGatewayEndpointRejectsNonLoopbackCleartextWsUrls() { val parsed = parseGatewayEndpoint("ws://gateway.example") + assertNull(parsed) + } + + @Test + fun parseGatewayEndpointOmitsExplicitDefaultTlsPortFromDisplayUrl() { + val parsed = parseGatewayEndpoint("https://gateway.example:443") + assertEquals( GatewayEndpointConfig( host = "gateway.example", + port = 443, + tls = true, + displayUrl = "https://gateway.example", + ), + parsed, + ) + } + + @Test + fun parseGatewayEndpointAllowsLoopbackCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://127.0.0.1") + + assertEquals( + GatewayEndpointConfig( + host = "127.0.0.1", port = 18789, tls = false, - displayUrl = "http://gateway.example:18789", + displayUrl = "http://127.0.0.1:18789", ), parsed, ) } @Test - fun parseGatewayEndpointOmitsExplicitDefaultTlsPortFromDisplayUrl() { - val parsed = parseGatewayEndpoint("https://gateway.example:443") + fun parseGatewayEndpointAllowsLocalhostCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://localhost:18789") assertEquals( GatewayEndpointConfig( - host = "gateway.example", - port = 443, - tls = true, - displayUrl = "https://gateway.example", + host = "localhost", + port = 18789, + tls = false, + displayUrl = "http://localhost:18789", ), parsed, ) } @Test - fun parseGatewayEndpointKeepsExplicitNonDefaultPortInDisplayUrl() { - val parsed = parseGatewayEndpoint("http://gateway.example:8080") + fun parseGatewayEndpointAllowsAndroidEmulatorCleartextWsUrls() { + val parsed = parseGatewayEndpoint("ws://10.0.2.2:18789") assertEquals( GatewayEndpointConfig( - host = "gateway.example", - port = 8080, + host = "10.0.2.2", + port = 18789, tls = false, - displayUrl = "http://gateway.example:8080", + displayUrl = "http://10.0.2.2:18789", ), parsed, ) } @Test - fun parseGatewayEndpointKeepsExplicitCleartextPort80InDisplayUrl() { - val parsed = parseGatewayEndpoint("http://gateway.example:80") + 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 = "gateway.example", + 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,18 +295,18 @@ 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", fallbackToken = "", 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 {
CHANGELOG.md+1 −0 modified@@ -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
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/a941a4fef9bc43b2973c92d0dcff5b8a426210c5nvdPatchWEB
- github.com/advisories/GHSA-83f3-hh45-vfw9ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-83f3-hh45-vfw9nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-40045ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-cleartext-credential-transmission-via-unencrypted-websocket-gateway-endpointsnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.