diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4aacec4c3..130d70d2a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ Docs: https://docs.openclaw.ai - Mobile pairing/security: fail closed for internal `/pair` setup-code issuance, cleanup, and approval paths when gateway pairing scopes are missing, and keep approval-time requested-scope enforcement on the internal command path. (#55996) Thanks @coygeek. - Exec approvals/node host: forward prepared `system.run` approval plans on the async node invoke path so mutable script operands keep their approval-time binding and drift revalidation instead of dropping back to unbound execution. - Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out. +- Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation. ## 2026.4.2 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt index ebc739c452f..5cdf12a7496 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt @@ -14,30 +14,29 @@ object CanvasActionTrust { if (candidateUri.scheme.equals("file", ignoreCase = true)) { return false } + val normalizedCandidate = normalizeTrustedRemoteA2uiUri(candidateUri) ?: return false return trustedA2uiUrls.any { trusted -> - isTrustedA2uiPage(candidateUri, trusted) + isTrustedA2uiPage(normalizedCandidate, trusted) } } private fun isTrustedA2uiPage(candidateUri: URI, trustedUrl: String): Boolean { val trustedUri = parseUri(trustedUrl) ?: return false - if (!candidateUri.scheme.equals(trustedUri.scheme, ignoreCase = true)) return false - if (candidateUri.host?.equals(trustedUri.host, ignoreCase = true) != true) return false - if (effectivePort(candidateUri) != effectivePort(trustedUri)) return false - - val trustedPath = trustedUri.rawPath?.takeIf { it.isNotBlank() } ?: return false - val candidatePath = candidateUri.rawPath?.takeIf { it.isNotBlank() } ?: return false - val trustedPrefix = if (trustedPath.endsWith("/")) trustedPath else "$trustedPath/" - return candidatePath == trustedPath || candidatePath.startsWith(trustedPrefix) + val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false + return candidateUri == normalizedTrusted } - private fun effectivePort(uri: URI): Int { - if (uri.port >= 0) return uri.port - return when (uri.scheme?.lowercase()) { - "https" -> 443 - "http" -> 80 - else -> -1 + private fun normalizeTrustedRemoteA2uiUri(uri: URI): URI? { + val scheme = uri.scheme?.lowercase() ?: return null + if (scheme != "http" && scheme != "https") return null + + val host = uri.host?.trim()?.takeIf { it.isNotEmpty() }?.lowercase() ?: return null + + return try { + URI(scheme, uri.userInfo, host, uri.port, uri.rawPath, uri.rawQuery, null) + } catch (_: Throwable) { + null } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt index 5298ba6f39d..1d93555eeeb 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt @@ -39,4 +39,34 @@ class CanvasActionTrustTest { ), ) } + + @Test + fun acceptsFragmentOnlyDifferenceForTrustedA2uiPage() { + assertTrue( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android#step2", + trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"), + ), + ) + } + + @Test + fun rejectsQueryMismatchOnTrustedOriginAndPath() { + assertFalse( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=ios", + trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"), + ), + ) + } + + @Test + fun rejectsDescendantPathUnderTrustedA2uiRoot() { + assertFalse( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/child/index.html?platform=android", + trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"), + ), + ) + } }