From 8b02ef133275be96d8aac2283100016c8a7f32e5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Mar 2026 01:24:51 -0700 Subject: [PATCH] fix(android): gate canvas bridge to trusted pages (#52722) * fix(android): gate canvas bridge to trusted pages * fix(changelog): note android canvas bridge gating * Update apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(android): snapshot canvas URL on UI thread --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + .../java/ai/openclaw/app/MainViewModel.kt | 4 ++ .../main/java/ai/openclaw/app/NodeRuntime.kt | 4 ++ .../java/ai/openclaw/app/node/A2UIHandler.kt | 7 +++ .../ai/openclaw/app/node/CanvasActionTrust.kt | 50 +++++++++++++++++++ .../java/ai/openclaw/app/ui/CanvasScreen.kt | 24 ++++++++- .../app/node/CanvasActionTrustTest.kt | 42 ++++++++++++++++ 7 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d4d8f70e35..fdae6a19e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc. - CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD. - CLI/configure: clarify fresh-setup memory-search warnings so they say semantic recall needs at least one embedding provider, and scope the initial model allowlist picker to the provider selected in configure. Thanks @vincentkoc. +- Android/canvas: ignore bridge messages from pages outside the bundled scaffold and trusted A2UI surfaces. Thanks @vincentkoc. - CLI/status: keep `status --json` stdout clean by skipping plugin compatibility scans that were not rendered in the JSON payload. (#52449) Thanks @cgdusek. - Browser/node proxy: enforce `nodeHost.browserProxy.allowProfiles` across `query.profile` and `body.profile`, block proxy-side profile create/delete when the allowlist is set, and keep the default full proxy surface when the allowlist is empty. - Web tools/Exa: align the bundled Exa plugin with the current Exa API by supporting newer search types and richer `contents` options, while fixing the result-count cap to honor Exa's higher limit. Thanks @vincentkoc. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 0add840cf30..38d81d4f8d5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -237,6 +237,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson) } + fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean { + return ensureRuntime().isTrustedCanvasActionUrl(rawUrl) + } + fun requestCanvasRehydrate(source: String = "screen_tab") { ensureRuntime().requestCanvasRehydrate(source = source, force = true) } 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 0149aa9d09b..09bab5f3ea6 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 @@ -904,6 +904,10 @@ class NodeRuntime( } } + fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean { + return a2uiHandler.isTrustedCanvasActionUrl(rawUrl) + } + fun loadChat(sessionKey: String) { val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() } chat.load(key) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt index 1938cf308dd..5377558ae87 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt @@ -13,6 +13,13 @@ class A2UIHandler( private val getNodeCanvasHostUrl: () -> String?, private val getOperatorCanvasHostUrl: () -> String?, ) { + fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean { + return CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = rawUrl, + trustedA2uiUrls = listOfNotNull(resolveA2uiHostUrl()), + ) + } + fun resolveA2uiHostUrl(): String? { val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty() val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty() 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 new file mode 100644 index 00000000000..ebc739c452f --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasActionTrust.kt @@ -0,0 +1,50 @@ +package ai.openclaw.app.node + +import java.net.URI + +object CanvasActionTrust { + const val scaffoldAssetUrl: String = "file:///android_asset/CanvasScaffold/scaffold.html" + + fun isTrustedCanvasActionUrl(rawUrl: String?, trustedA2uiUrls: List): Boolean { + val candidate = rawUrl?.trim().orEmpty() + if (candidate.isEmpty()) return false + if (candidate == scaffoldAssetUrl) return true + + val candidateUri = parseUri(candidate) ?: return false + if (candidateUri.scheme.equals("file", ignoreCase = true)) { + return false + } + + return trustedA2uiUrls.any { trusted -> + isTrustedA2uiPage(candidateUri, 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) + } + + 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 parseUri(raw: String): URI? = + try { + URI(raw) + } catch (_: Throwable) { + null + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt index 73a931b488f..cfd635d8fa0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature import ai.openclaw.app.MainViewModel +import java.util.concurrent.atomic.AtomicReference @SuppressLint("SetJavaScriptEnabled") @Composable @@ -29,6 +30,7 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier val context = LocalContext.current val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 val webViewRef = remember { mutableStateOf(null) } + val currentPageUrlRef = remember { AtomicReference(null) } DisposableEffect(viewModel) { onDispose { @@ -68,6 +70,14 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier isHorizontalScrollBarEnabled = true webViewClient = object : WebViewClient() { + override fun onPageStarted( + view: WebView, + url: String?, + favicon: android.graphics.Bitmap?, + ) { + currentPageUrlRef.set(url) + } + override fun onReceivedError( view: WebView, request: WebResourceRequest, @@ -90,6 +100,7 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier } override fun onPageFinished(view: WebView, url: String?) { + currentPageUrlRef.set(url) if (isDebuggable) { Log.d("OpenClawWebView", "onPageFinished: $url") } @@ -122,7 +133,12 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier } } - val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) } + val bridge = + CanvasA2UIActionBridge( + isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) }, + ) { payload -> + viewModel.handleCanvasA2UIActionFromWebView(payload) + } addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName) viewModel.canvas.attach(this) webViewRef.value = this @@ -147,11 +163,15 @@ private fun disableForceDarkIfSupported(settings: WebSettings) { WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) } -private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { +private class CanvasA2UIActionBridge( + private val isTrustedPage: () -> Boolean, + private val onMessage: (String) -> Unit, +) { @JavascriptInterface fun postMessage(payload: String?) { val msg = payload?.trim().orEmpty() if (msg.isEmpty()) return + if (!isTrustedPage()) return onMessage(msg) } 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 new file mode 100644 index 00000000000..5298ba6f39d --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/CanvasActionTrustTest.kt @@ -0,0 +1,42 @@ +package ai.openclaw.app.node + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CanvasActionTrustTest { + @Test + fun acceptsBundledScaffoldAsset() { + assertTrue(CanvasActionTrust.isTrustedCanvasActionUrl(CanvasActionTrust.scaffoldAssetUrl, emptyList())) + } + + @Test + fun acceptsTrustedA2uiPageOnAdvertisedCanvasHost() { + assertTrue( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android", + trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"), + ), + ) + } + + @Test + fun rejectsDifferentOriginEvenIfPathMatches() { + assertFalse( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "https://evil.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android", + trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"), + ), + ) + } + + @Test + fun rejectsUntrustedCanvasPagePathOnTrustedOrigin() { + assertFalse( + CanvasActionTrust.isTrustedCanvasActionUrl( + rawUrl = "https://canvas.example.com:9443/untrusted/index.html", + trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"), + ), + ) + } +}