Skip to content

Commit 6495358

Browse files
authored
fix: Android setup codes accept local mDNS gateway hosts (#98439)
* Fix Android mDNS setup-code cleartext policy * Align Android gateway diagnostics cleartext guidance
1 parent 0c7bac3 commit 6495358

5 files changed

Lines changed: 181 additions & 8 deletions

File tree

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ internal fun isLoopbackGatewayHost(
4848
return isMappedIpv4 && address[12] == 127.toByte()
4949
}
5050

51-
/** Allows cleartext only for loopback and private/link-local network ranges. */
51+
/** Allows cleartext only for loopback, `.local`, and private/link-local network ranges. */
5252
internal fun isLocalCleartextGatewayHost(
5353
rawHost: String?,
5454
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
@@ -62,14 +62,17 @@ internal fun isLocalCleartextGatewayHost(
6262
if (host.endsWith(".")) {
6363
host = host.dropLast(1)
6464
}
65+
if (host.isEmpty()) return false
66+
if (isLoopbackGatewayHost(host, allowEmulatorBridgeAlias = allowEmulatorBridgeAlias)) return true
67+
if (isMdnsLocalHostname(host)) return true
68+
6569
val zoneIndex = host.indexOf('%')
6670
if (zoneIndex >= 0) {
6771
// Link-local cleartext policy is about the address range; strip the
6872
// interface zone before InetAddress parsing rejects otherwise valid hosts.
6973
host = host.substring(0, zoneIndex)
7074
}
7175
if (host.isEmpty()) return false
72-
if (isLoopbackGatewayHost(host, allowEmulatorBridgeAlias = allowEmulatorBridgeAlias)) return true
7376

7477
parseIpv4Address(host)?.let { ipv4 ->
7578
val first = ipv4[0].toInt() and 0xff
@@ -127,5 +130,19 @@ private fun parseIpv4Address(host: String): ByteArray? {
127130
return bytes
128131
}
129132

133+
private fun isMdnsLocalHostname(host: String): Boolean {
134+
if (host.length > 253) return false
135+
if (!host.endsWith(".local")) return false
136+
val labels = host.split('.')
137+
if (labels.size < 2 || labels.last() != "local") return false
138+
return labels.dropLast(1).all(::isDnsHostnameLabel)
139+
}
140+
141+
private fun isDnsHostnameLabel(label: String): Boolean {
142+
if (label.isEmpty() || label.length > 63) return false
143+
if (label.first() == '-' || label.last() == '-') return false
144+
return label.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
145+
}
146+
130147
/** Cheap prefilter before handing potential IPv6 literals to InetAddress. */
131148
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/ui/GatewayConfigResolver.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ internal data class GatewayScannedSetupCodeResult(
6363

6464
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
6565
private const val remoteGatewaySecurityRule =
66-
"Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, the Android emulator, and private LAN IPs."
66+
"Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs."
6767
private const val remoteGatewaySecurityFix =
6868
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
6969

apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ internal fun buildGatewayDiagnosticsReport(
5656
Please:
5757
- pick one route only: same machine, same LAN, Tailscale, or public URL
5858
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
59-
- remember: public routes require wss:// or Tailscale Serve; ws:// is allowed for localhost, the Android emulator, and private LAN IPs
59+
- remember: public routes require wss:// or Tailscale Serve; ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs
6060
- quote the exact app status/error below
6161
- tell me whether `openclaw devices list` should show a pending pairing request
6262
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`

apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,20 @@ class ConnectionManagerTest {
123123
assertNull(params)
124124
}
125125

126+
@Test
127+
fun resolveTlsParamsForEndpoint_manualMdnsRespectsManualTlsToggle() {
128+
val endpoint = GatewayEndpoint.manual(host = "gateway.local", port = 18789)
129+
130+
val params =
131+
ConnectionManager.resolveTlsParamsForEndpoint(
132+
endpoint,
133+
storedFingerprint = null,
134+
manualTlsEnabled = false,
135+
)
136+
137+
assertNull(params)
138+
}
139+
126140
@Test
127141
fun resolveTlsParamsForEndpoint_manualPrivateLanCleartextCanOverrideStoredPin() {
128142
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
@@ -185,6 +199,30 @@ class ConnectionManagerTest {
185199
assertEquals(false, params?.allowTOFU)
186200
}
187201

202+
@Test
203+
fun resolveTlsParamsForEndpoint_discoveryMdnsWithoutHintsStillRequiresTls() {
204+
val endpoint =
205+
GatewayEndpoint(
206+
stableId = "_openclaw-gw._tcp.|local.|Test",
207+
name = "Test",
208+
host = "gateway.local",
209+
port = 18789,
210+
tlsEnabled = false,
211+
tlsFingerprintSha256 = null,
212+
)
213+
214+
val params =
215+
ConnectionManager.resolveTlsParamsForEndpoint(
216+
endpoint,
217+
storedFingerprint = null,
218+
manualTlsEnabled = false,
219+
)
220+
221+
assertEquals(true, params?.required)
222+
assertNull(params?.expectedFingerprint)
223+
assertEquals(false, params?.allowTOFU)
224+
}
225+
188226
@Test
189227
fun resolveTlsParamsForEndpoint_discoveryLoopbackWithoutHintsCanStayCleartext() {
190228
val endpoint =
@@ -258,9 +296,16 @@ class ConnectionManagerTest {
258296
}
259297

260298
@Test
261-
fun isLocalCleartextGatewayHost_acceptsLanIpsButRejectsMdnsAndTailnetHosts() {
299+
fun isLocalCleartextGatewayHost_acceptsLanIpsAndMdnsButRejectsRemoteHosts() {
262300
assertTrue(isLocalCleartextGatewayHost("192.168.1.20"))
263-
assertFalse(isLocalCleartextGatewayHost("gateway.local"))
301+
assertTrue(isLocalCleartextGatewayHost("gateway.local"))
302+
assertTrue(isLocalCleartextGatewayHost("GATEWAY.LOCAL."))
303+
assertFalse(isLocalCleartextGatewayHost("gateway.local.evil.com"))
304+
assertFalse(isLocalCleartextGatewayHost("gatewaylocal"))
305+
assertFalse(isLocalCleartextGatewayHost("local"))
306+
assertFalse(isLocalCleartextGatewayHost(".local"))
307+
assertFalse(isLocalCleartextGatewayHost("gateway..local"))
308+
assertFalse(isLocalCleartextGatewayHost("gateway.local%25wlan0"))
264309
assertFalse(isLocalCleartextGatewayHost("100.64.0.9"))
265310
assertFalse(isLocalCleartextGatewayHost("gateway.tailnet.ts.net"))
266311
}

apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,44 @@ class GatewayConfigResolverTest {
114114
}
115115

116116
@Test
117-
fun parseGatewayEndpointRejectsMdnsCleartextWsUrls() {
117+
fun parseGatewayEndpointAllowsMdnsCleartextWsUrls() {
118118
val parsed = parseGatewayEndpoint("ws://gateway.local:18789")
119119

120-
assertNull(parsed)
120+
assertEquals(
121+
GatewayEndpointConfig(
122+
host = "gateway.local",
123+
port = 18789,
124+
tls = false,
125+
displayUrl = "http://gateway.local:18789",
126+
),
127+
parsed,
128+
)
129+
}
130+
131+
@Test
132+
fun parseGatewayEndpointAllowsNormalizedMdnsCleartextWsUrls() {
133+
val parsed = parseGatewayEndpoint("ws://GATEWAY.LOCAL.:18789")
134+
135+
assertEquals("GATEWAY.LOCAL.", parsed?.host)
136+
assertEquals(18789, parsed?.port)
137+
assertEquals(false, parsed?.tls)
138+
}
139+
140+
@Test
141+
fun parseGatewayEndpointRejectsMdnsSuffixAndLabelBypasses() {
142+
val rejected =
143+
listOf(
144+
"ws://gateway.local.evil.com:18789",
145+
"ws://gatewaylocal:18789",
146+
"ws://local:18789",
147+
"ws://.local:18789",
148+
"ws://gateway..local:18789",
149+
"ws://gateway.local%25wlan0:18789",
150+
)
151+
152+
for (url in rejected) {
153+
assertNull(url, parseGatewayEndpoint(url))
154+
}
121155
}
122156

123157
@Test
@@ -279,6 +313,17 @@ class GatewayConfigResolverTest {
279313
assertNull(resolved.error)
280314
}
281315

316+
@Test
317+
fun resolveScannedSetupCodeResultAcceptsMdnsCleartextGateway() {
318+
val setupCode =
319+
encodeSetupCode("""{"url":"ws://gateway.local:18789","bootstrapToken":"bootstrap-1"}""")
320+
321+
val resolved = resolveScannedSetupCodeResult(setupCode)
322+
323+
assertEquals(setupCode, resolved.setupCode)
324+
assertNull(resolved.error)
325+
}
326+
282327
@Test
283328
fun resolveScannedSetupCodeResultFlagsInsecureRemoteGateway() {
284329
val setupCode =
@@ -322,6 +367,22 @@ class GatewayConfigResolverTest {
322367
assertNull(parsed.error)
323368
}
324369

370+
@Test
371+
fun parseGatewayEndpointResultAllowsMdnsCleartextGateway() {
372+
val parsed = parseGatewayEndpointResult("ws://gateway.local:18789")
373+
374+
assertEquals(
375+
GatewayEndpointConfig(
376+
host = "gateway.local",
377+
port = 18789,
378+
tls = false,
379+
displayUrl = "http://gateway.local:18789",
380+
),
381+
parsed.config,
382+
)
383+
assertNull(parsed.error)
384+
}
385+
325386
@Test
326387
fun decodeGatewaySetupCodeParsesBootstrapToken() {
327388
val setupCode =
@@ -391,6 +452,34 @@ class GatewayConfigResolverTest {
391452
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
392453
}
393454

455+
@Test
456+
fun resolveGatewayConnectConfigAllowsMdnsCleartextSetupCode() {
457+
val setupCode =
458+
encodeSetupCode("""{"url":"ws://gateway.local:18789","bootstrapToken":"bootstrap-1"}""")
459+
460+
val resolved =
461+
resolveGatewayConnectConfig(
462+
useSetupCode = true,
463+
setupCode = setupCode,
464+
savedManualHost = "",
465+
savedManualPort = "",
466+
savedManualTls = false,
467+
manualHostInput = "",
468+
manualPortInput = "",
469+
manualTlsInput = false,
470+
fallbackBootstrapToken = "",
471+
fallbackToken = "shared-token",
472+
fallbackPassword = "shared-password",
473+
)
474+
475+
assertEquals("gateway.local", resolved?.host)
476+
assertEquals(18789, resolved?.port)
477+
assertEquals(false, resolved?.tls)
478+
assertEquals("bootstrap-1", resolved?.bootstrapToken)
479+
assertNull(resolved?.token?.takeIf { it.isNotEmpty() })
480+
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
481+
}
482+
394483
@Test
395484
fun resolveGatewayConnectConfigManualPreservesBootstrapTokenWhenNoReplacementAuthExists() {
396485
val resolved =
@@ -481,6 +570,28 @@ class GatewayConfigResolverTest {
481570
assertEquals(false, resolved?.tls)
482571
}
483572

573+
@Test
574+
fun resolveGatewayConnectConfigAllowsMdnsManualCleartextEndpoint() {
575+
val resolved =
576+
resolveGatewayConnectConfig(
577+
useSetupCode = false,
578+
setupCode = "",
579+
savedManualHost = "",
580+
savedManualPort = "",
581+
savedManualTls = false,
582+
manualHostInput = "gateway.local",
583+
manualPortInput = "18789",
584+
manualTlsInput = false,
585+
fallbackBootstrapToken = "bootstrap-1",
586+
fallbackToken = "",
587+
fallbackPassword = "",
588+
)
589+
590+
assertEquals("gateway.local", resolved?.host)
591+
assertEquals(18789, resolved?.port)
592+
assertEquals(false, resolved?.tls)
593+
}
594+
484595
@Test
485596
fun composeGatewayManualUrlDefaultsPortTo443WhenTlsAndPortBlank() {
486597
val url = composeGatewayManualUrl("mydevice.tail1234.ts.net", "", tls = true)

0 commit comments

Comments
 (0)