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

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.22026.4.2

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.4.2

Patches

1
a941a4fef9bc

fix(android): require TLS for remote gateway endpoints (#58475)

https://github.com/openclaw/openclawAgustin RiveraApr 2, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.