fix(android): rename app package to ai.openclaw.app#38712
Conversation
|
Too many files changed for review. ( |
🔒 Aisle Security AnalysisWe found 15 potential security issue(s) in this PR:
1. 🟠 Ed25519 private key stored unencrypted in filesDir and included in Android backups
DescriptionThe app persists a long-lived Ed25519 private key (PKCS8, base64) to internal storage as plaintext JSON. At the same time, Android backup/transfer is enabled and configured to include the entire internal file domain. Impact:
This can expose the device private key via cloud backup or device-to-device transfer exports, enabling impersonation/signature forgery if the backup is obtained by an attacker. RecommendationPrevent backup exposure and avoid storing the private key unencrypted.
<!-- res/xml/backup_rules.xml -->
<full-backup-content>
<exclude domain="file" path="openclaw/identity/" />
</full-backup-content><!-- res/xml/data_extraction_rules.xml -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="openclaw/identity/" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="openclaw/identity/" />
</device-transfer>
</data-extraction-rules>Or set: <application android:allowBackup="false" ... />
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedFile = EncryptedFile.Builder(
context,
identityFile,
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
encryptedFile.openFileOutput().use { it.write(encoded.toByteArray(Charsets.UTF_8)) }2. 🟠 Unbounded Base64 and bitmap decoding allows remote memory-exhaustion DoS in chat rendering
DescriptionThe chat UI decodes and renders Base64-encoded images from remotely-supplied chat messages (attachments and inline markdown
Impact:
Vulnerable code: val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)RecommendationAdd strict limits and safe decoding for untrusted Base64 inputs.
private const val MAX_BASE64_CHARS = 400_000 // ~300KB decoded (3/4 of base64)
private fun estimatedDecodedBytes(base64Chars: Int): Int = (base64Chars * 3) / 4
if (base64.length > MAX_BASE64_CHARS) return Base64ImageState(image = null, failed = true)
val bytes = Base64.decode(base64, Base64.DEFAULT)
val optsBounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, optsBounds)
val maxDim = 2048
val sampleSize = calculateInSampleSize(optsBounds.outWidth, optsBounds.outHeight, maxDim)
val opts = BitmapFactory.Options().apply {
inSampleSize = sampleSize
inPreferredConfig = Bitmap.Config.RGB_565
}
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) ?: return null
3. 🟠 WebView JavaScript bridge exposed to arbitrary/untrusted origins (no navigation allowlist)
DescriptionThe app creates a WebView with JavaScript enabled and registers a Because navigation is not origin-restricted (no scheme/host allowlist) and mixed content is permitted, an attacker-controlled page (or injected script via redirect/MITM) can call the JS bridge and trigger privileged app behavior. Why this is exploitable
Concrete exploit scenario
Additional risk
Vulnerable code: // CanvasScreen.kt
settings.javaScriptEnabled = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
...
addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName)// CanvasController.kt
fun navigate(url: String) {
...
wv.loadUrl(currentUrl)
}// NodeRuntime.kt
nodeSession.sendNodeEvent(event = "agent.request", payloadJson = ...)RecommendationRestrict the bridge to trusted content and harden WebView settings. 1) Enforce a strict navigation allowlist (scheme + host + path prefix). Block any navigation that is not your trusted canvas/A2UI origin. 2) Do not use 3) Disable mixed content. Use 4) Harden WebView defaults (as appropriate for your app): disable file/content access and enable Safe Browsing. Example (sketch): val trustedOrigin = "https://your-canvas-host.example" // or derived from pinned gateway + capability URL
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
allowFileAccess = false
allowContentAccess = false
safeBrowsingEnabled = true
}
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
val allowed = url.scheme == "https" && url.toString().startsWith(trustedOrigin)
return !allowed // block everything else
}
}
// Prefer origin-scoped message listener over addJavascriptInterface
WebViewCompat.addWebMessageListener(
webView,
"openclawCanvasA2UIAction",
setOf(trustedOrigin),
) { _, message, _, _, _ ->
viewModel.handleCanvasA2UIActionFromWebView(message.data ?: "")
}If 4. 🟠 TalkModeManager TTS plays gateway events when sessionKey is missing (cross-session privacy leak)
Description
This matters because the gateway agent-event broadcaster can emit Impact when
Vulnerable code (accepts events when val eventSession = payload["sessionKey"]?.asStringOrNull()
val activeSession = mainSessionKey.ifBlank { "main" }
if (eventSession != null && eventSession != activeSession) returnThe same pattern exists in RecommendationMake session filtering fail closed for TTS paths:
Example fix: val eventSession = payload["sessionKey"]?.asStringOrNull()?.trim()
val activeSession = mainSessionKey.ifBlank { "main" }
if (eventSession.isNullOrEmpty() || eventSession != activeSession) returnApply the same logic in both:
5. 🟠 Setup code parsing permits plaintext ws/http endpoints, risking credential exposure
Description
Vulnerable code: val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty()
val tls =
when (scheme) {
"ws", "http" -> false
"wss", "https" -> true
else -> true
}This accepts insecure endpoints based on untrusted QR/setup-code input rather than enforcing a secure transport policy. RecommendationEnforce secure schemes for setup codes (and ideally for manual entry by default):
Example: val scheme = uri.scheme?.trim()?.lowercase(Locale.US)
if (scheme != null && scheme !in setOf("wss", "https")) return null
val tls = true // setup codes require TLSIf you must support 6. 🟠 Unrestricted WebView navigation + remote JavaScript execution enables local resource access and data exfiltration
DescriptionThe gateway-controlled invoke layer allows arbitrary URLs to be loaded into an in-app This creates a concrete exfiltration path if the gateway/operator is compromised or untrusted:
Impact examples:
Vulnerable code (navigation without validation): private fun reload() {
val currentUrl = url
withWebViewOnMain { wv ->
if (currentUrl == null) {
wv.loadUrl(scaffoldAssetUrl)
} else {
wv.loadUrl(currentUrl)
}
}
}Related code paths:
RecommendationImplement a strict navigation allowlist and harden
import android.net.Uri
private fun isAllowedCanvasUrl(raw: String): Boolean {
val uri = runCatching { Uri.parse(raw.trim()) }.getOrNull() ?: return false
val scheme = (uri.scheme ?: "").lowercase()
// Allow only https (and optionally http for local dev), and only specific hosts.
if (scheme != "https") return false
val host = (uri.host ?: "").lowercase()
val allowedHosts = setOf("your-gateway-host.example")
return host in allowedHosts
}
fun navigate(url: String) {
val trimmed = url.trim()
if (trimmed.isNotBlank() && trimmed != "/" && !isAllowedCanvasUrl(trimmed)) {
// reject / reset to scaffold
this.url = null
_currentUrl.value = null
reload()
return
}
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
_currentUrl.value = this.url
reload()
}
settings.allowFileAccess = false
settings.allowContentAccess = false
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
// Also consider leaving these as false explicitly:
settings.allowFileAccessFromFileURLs = false
settings.allowUniversalAccessFromFileURLs = falseIf you must load local assets, consider using
7. 🟠 Manual gateway connections can use plaintext WebSocket, exposing tokens/passwords
DescriptionThe Android client allows manual connections without TLS (
This enables passive network attackers (same Wi‑Fi/LAN, captive portal, malicious router) to capture gateway credentials and potentially impersonate the device/operator. RecommendationRequire transport security for any connection that may send secrets. Suggested fixes (pick one or combine):
if (isManual && !manualTlsEnabled) {
throw IllegalStateException("Manual connections must use TLS")
}
if (tls == null && (!token.isNullOrBlank() || !password.isNullOrBlank())) {
throw IllegalStateException("Refusing to send credentials without TLS")
}Also update onboarding/quick-fill defaults so 8. 🟠 Untrusted
|
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-918 |
| Location | apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt:371-663 |
Description
GatewaySession accepts canvasHostUrl from the gateway connect response and stores it after minimal normalization. For non-loopback hosts it returns the gateway-provided URL essentially as-is, without validating scheme/host/port against the connected GatewayEndpoint.
This value is later used to build the A2UI URL and is loaded into an Android WebView with JavaScript enabled:
- Input (untrusted):
canvasHostUrlfrom gateway RPC payload (connect) - Propagation: stored in
GatewaySession.canvasHostUrl - Sink:
A2UIHandler.resolveA2uiHostUrl()builds a URL andCanvasController.navigate()/WebView.loadUrl()loads it
Impact:
- A malicious/spoofed gateway (or MITM on non-TLS connections) can provide a
canvasHostUrlpointing to arbitrary hosts/schemes. - The app will then load attacker-controlled content inside a WebView (JS enabled, mixed content allowed), enabling SSRF-like network access from the device and potential phishing / exploitation of WebView, and it also exposes a JS bridge (
openclawCanvasA2UIAction.postMessage) to any loaded page.
Vulnerable code (accepts arbitrary host URL):
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
// ...
if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) {
// ...only rewrites scheme/port for TLS
return trimmed
}Recommendation
Treat canvasHostUrl as untrusted and strictly validate it before storing/using it.
Suggested approach (OkHttp HttpUrl + allowlist):
- Parse with
toHttpUrlOrNull()(reject non-HTTP(S) schemes) - Enforce
hostis one of the expected endpoint hosts (e.g.,endpoint.host,endpoint.lanHost,endpoint.tailnetDns) - Enforce port is expected (
endpoint.canvasPort/endpoint.port) and ensure TLS useshttps - Rebuild a canonical URL from validated components (drop userinfo, normalize path)
Example:
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
private fun normalizeCanvasHostUrlStrict(raw: String?, endpoint: GatewayEndpoint, isTls: Boolean): String? {
val url = raw?.trim()?.toHttpUrlOrNull() ?: return null
val schemeOk = url.scheme == "http" || url.scheme == "https"
if (!schemeOk) return null
val allowedHosts = setOfNotNull(endpoint.host.trim().lowercase(), endpoint.lanHost?.trim()?.lowercase(), endpoint.tailnetDns?.trim()?.lowercase())
if (url.host.lowercase() !in allowedHosts) return null
val expectedPort = if (isTls) endpoint.port else (endpoint.canvasPort ?: endpoint.port)
if (url.port != expectedPort) return null
val enforcedScheme = if (isTls) "https" else url.scheme
return url.newBuilder().scheme(enforcedScheme).port(expectedPort).build().toString()
}Additionally, consider setting a restrictive WebViewClient.shouldOverrideUrlLoading policy to prevent navigation away from the validated origin.
9. 🟡 Unbounded in-memory buffering in StreamingMediaDataSource can lead to OOM/DoS during long TTS streams
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-400 |
| Location | apps/android/app/src/main/java/ai/openclaw/app/voice/StreamingMediaDataSource.kt:9-24 |
Description
StreamingMediaDataSource retains all streamed audio chunks in memory and never evicts consumed chunks.
- Network streaming (
TalkModeManager.streamTts) appends each 8KB read intoStreamingMediaDataSource.append(). StreamingMediaDataSourcestores each chunk in anArrayListand increasestotalSize, but does not drop data that has already been read byMediaPlayer.- For long responses (or a maliciously-encouraged extremely long assistant reply), memory usage grows roughly linearly with audio duration until the app is killed (OOM), resulting in a denial-of-service.
Vulnerable code:
private val chunks = ArrayList<Chunk>()
...
fun append(data: ByteArray) {
val chunk = Chunk(totalSize, data)
chunks.add(chunk)
totalSize += data.size.toLong()
}and the streaming loop:
val buffer = ByteArray(8 * 1024)
while (true) {
val read = input.read(buffer)
if (read <= 0) break
sink.append(buffer.copyOf(read))
}Recommendation
Add bounded buffering and eviction of already-consumed chunks (or switch to file-backed buffering).
Options:
- Evict consumed chunks based on the last read position (best-effort sequential playback):
private var minRetainedPos: Long = 0
private val maxBufferedBytes: Long = 10L * 1024 * 1024 // 10MB cap
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
synchronized(lock) {
// ... existing wait logic ...
val bytesRead = /* existing copy logic */
minRetainedPos = maxOf(minRetainedPos, position + bytesRead)
// Evict chunks that end before minRetainedPos
while (chunks.isNotEmpty()) {
val first = chunks.first()
val end = first.start + first.data.size
if (end <= minRetainedPos) chunks.removeAt(0) else break
}
// Enforce a hard cap; if exceeded, fail/stop to avoid OOM
val buffered = totalSize - (chunks.firstOrNull()?.start ?: totalSize)
if (buffered > maxBufferedBytes) {
closed = true
throw IllegalStateException("TTS buffer exceeded")
}
return bytesRead
}
}- Stream to a temp file (already implemented as
streamAndPlayViaFile) and play from disk, which avoids unbounded heap growth entirely.
Also consider imposing a maximum assistant text length / maximum audio duration to cap worst-case resource usage.
10. 🟡 App update URL host/scheme validation bypass via OkHttp redirects
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-918 |
| Location | apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt:148-153 |
Description
The app.update implementation validates the initial update URL (must be https and host must equal the connected gateway host), but the actual download is performed with a default-configured OkHttpClient that follows redirects by default.
As a result, a gateway-controlled endpoint (or any attacker able to influence responses from the allowed host) can return a 30x redirect to an arbitrary URL, causing the node to:
- make outbound requests to other hosts (SSRF / network pivot)
- potentially downgrade to cleartext HTTP (
https -> http) if redirected (MITM risk for the APK download) - download content from an unvalidated origin (host check is not re-applied to the final URL)
Even if the later SHA-256 check fails, the redirected request is still made, enabling SSRF-style interactions with internal resources.
Vulnerable code:
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(300, java.util.concurrent.TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder().url(url).build()
val response = client.newCall(request).execute()Recommendation
Prevent redirects from bypassing URL validation.
Option A (simplest): disable redirects entirely and require the gateway to serve the APK directly:
val client = OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(300, TimeUnit.SECONDS)
.build()Option B: follow redirects manually with re-validation before each hop (recommended if redirects are needed):
- Parse URLs with
okhttp3.HttpUrl(more robust canonicalization) - For each redirect
Location, resolve it and enforce:scheme == "https"hostexactly matches the connected gateway host (consider also normalizing trailing dots / IDN viaHttpUrl.host)- optionally restrict
portto an expected set (e.g., 443 or the gateway’s configured port)
Example sketch:
val baseClient = OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.build()
fun validate(u: HttpUrl) {
require(u.scheme == "https")
require(u.host.equals(connectedHost, ignoreCase = true))
}
var current = url.toHttpUrl()
validate(current)
repeat(5) {
val resp = baseClient.newCall(Request.Builder().url(current).build()).execute()
if (resp.isRedirect) {
val loc = resp.header("Location") ?: error("redirect without Location")
val next = current.resolve(loc) ?: error("bad redirect")
validate(next)
current = next
} else {
// proceed to download
return@repeat
}
}11. 🟡 Remote-triggered arbitrary APK installation via app.update without package/signature validation
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-862 |
| Location | apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt:252-270 |
Description
The app.update invoke handler downloads an APK from a remotely-supplied URL and installs it via PackageInstaller after only checking a caller-provided SHA-256.
While Android will enforce signature matching for updates of the same package, this handler does not verify that the downloaded APK actually targets the current app package. As a result, any party able to issue an app.update invoke (e.g., a compromised gateway/server-side component, or an on-path attacker if the gateway session is not strongly protected) can cause the device to present an install prompt for an arbitrary third-party app (different package name), turning the node into a remote app installer.
Key issues:
- Remote input controls
urlandsha256(parseAppUpdateRequest), and thesha256is not anchored to any local trust decision. - No verification that the APK’s
packageNameequalsappContext.packageName. - No verification that the APK’s signing cert matches the currently installed app.
- No check that the version is an upgrade (anti-downgrade) at the app level.
Vulnerable code (installation occurs without validating the APK identity):
val sessionId = installer.createSession(params)
val session = installer.openSession(sessionId)
// ... write APK bytes ...
val callbackIntent = Intent(appContext, InstallResultReceiver::class.java)
val pi = PendingIntent.getBroadcast(
appContext, sessionId, callbackIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pi.intentSender)Recommendation
Restrict app.update to only install a verified update of the current app.
Minimum hardening (package + version check):
val pm = appContext.packageManager
val archiveInfo = pm.getPackageArchiveInfo(file.absolutePath, 0)
?: throw IllegalArgumentException("Invalid APK")
if (archiveInfo.packageName != appContext.packageName) {
throw IllegalArgumentException("Refusing to install non-self package")
}
// Optionally compare versionCode vs current
val current = pm.getPackageInfo(appContext.packageName, 0)
if (archiveInfo.longVersionCode <= current.longVersionCode) {
throw IllegalArgumentException("Refusing downgrade/same-version install")
}Stronger hardening (recommended):
- Verify the APK signing certificate matches the currently installed app’s signing cert (use
GET_SIGNING_CERTIFICATESand compare signer digests). - Consider pinning the download to the gateway’s authenticated transport (e.g., reuse the gateway TLS pin/fingerprint, or download through the already-authenticated gateway channel) rather than trusting a caller-provided SHA-256.
- Log and/or require explicit user consent in-app before starting the download/install flow.
12. 🟡 Unvalidated canvasCapability inserted into scoped canvas URL enables path/query injection
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-20 |
| Location | apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt:741-755 |
Description
replaceCanvasCapabilityInScopedHostUrl() rewrites a scoped canvas URL by directly concatenating the refreshed canvasCapability into the URL string without validation or URL-encoding.
- Input:
canvasCapabilitycomes from the gateway RPC responsenode.canvas.capability.refresh(refreshNodeCanvasCapability), i.e., network-controlled if the gateway is malicious or if the websocket connection can be tampered with. - Unsafe transformation: the capability is inserted verbatim into a URL path segment.
- Impact:
- A crafted capability containing reserved characters (e.g.
/,..,%2f,?,#) can change the effective request path/query/fragment. - In particular, injecting dot-segments like
../(or encoded variants) can cause URL normalization to escape the intended__openclaw__/cap/<token>/...scope, potentially bypassing capability-based scoping on the canvas host. - The resulting rewritten URL is later used to build the A2UI URL and is loaded into a JavaScript-enabled WebView (
A2UIHandler.resolveA2uiHostUrl→CanvasController.navigate→WebView.loadUrl), so rewriting to an unintended path can load unexpected web content.
- A crafted capability containing reserved characters (e.g.
Vulnerable code:
return scopedUrl.substring(0, capabilityStart) + capability + scopedUrl.substring(capabilityEnd)Recommendation
Treat canvasCapability as an untrusted URL path segment.
- Validate it against a strict allowlist (e.g., base64url token) and reject anything else.
- Modify URLs via a URL builder, not raw string splicing.
Example (OkHttp HttpUrl + strict regex):
private val CAP_RE = Regex("^[A-Za-z0-9_-]+$")
internal fun replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capability: String): String? {
val cap = capability.trim()
if (!CAP_RE.matches(cap)) return null
val url = scopedUrl.toHttpUrlOrNull() ?: return null
val segments = url.pathSegments
// Find /__openclaw__/cap/<capability>/...
val i = segments.indexOf("__openclaw__")
if (i < 0 || i + 2 >= segments.size) return null
if (segments[i + 1] != "cap") return null
return url.newBuilder()
.setPathSegment(i + 2, cap) // safe: sets a single segment
.build()
.toString()
}Additionally, consider rejecting capability that is too long to avoid memory/DoS issues (e.g., cap.length <= 256).
13. 🔵 Unbounded attachment ingestion reads entire selected image into memory before encoding (local memory-exhaustion DoS)
| Property | Value |
|---|---|
| Severity | Low |
| CWE | CWE-400 |
| Location | apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt:263-276 |
Description
When a user selects images to attach, loadImageAttachment() reads the entire ContentResolver stream into a ByteArrayOutputStream, then converts it to a ByteArray and Base64-encodes it—without any size limit.
- This can cause very large heap allocations for high-resolution images or large files mislabeled as images.
- Even though this is a local action, it can crash the app (denial of service) and can be triggered accidentally (or by another app providing a large
content://stream).
Vulnerable code:
val out = ByteArrayOutputStream()
input.copyTo(out)
out.toByteArray()
...
Base64.encodeToString(bytes, Base64.NO_WRAP)Recommendation
Apply a maximum byte limit and avoid loading unbounded streams into memory.
- Enforce a hard cap (e.g., 5–10MB) while copying:
private const val MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
fun readUpTo(input: InputStream, maxBytes: Int): ByteArray {
val out = ByteArrayOutputStream()
val buf = ByteArray(8 * 1024)
var total = 0
while (true) {
val n = input.read(buf)
if (n <= 0) break
total += n
if (total > maxBytes) throw IllegalStateException("attachment too large")
out.write(buf, 0, n)
}
return out.toByteArray()
}- Prefer scaling/compressing the image before Base64 encoding (e.g., decode bounds + downsample + JPEG re-encode) similar to existing
JpegSizeLimiterpatterns in the codebase. - Consider also enforcing limits on number of attachments and total bytes across attachments.
14. 🔵 Sensitive data exposure via logging of unknown TalkDirective JSON keys
| Property | Value |
|---|---|
| Severity | Low |
| CWE | CWE-532 |
| Location | apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt:846-850 |
Description
TalkModeManager.playAssistant() logs any unknown keys present in the first-line JSON directive header:
- Assistant/model output is untrusted input.
- JSON object keys can be arbitrary attacker-controlled strings.
- If a malicious model/user embeds a secret in a key name (e.g.,
{"<secret>":1}), the app will include that secret verbatim in Logcat. - Logs may be readable on rooted devices, via debug builds, or collected by crash/telemetry systems.
Vulnerable code:
val parsed = TalkDirectiveParser.parse(text)
if (parsed.unknownKeys.isNotEmpty()) {
Log.w(tag, "Unknown talk directive keys: ${parsed.unknownKeys}")
}Recommendation
Do not log attacker-controlled directive keys verbatim.
Safer options:
- Log only the count of unknown keys, not their values.
- Or redact/clip each key to a safe format (e.g., allowlist
[a-zA-Z0-9_\-]{1,32}and replace others with"<redacted>").
Example:
val parsed = TalkDirectiveParser.parse(text)
if (parsed.unknownKeys.isNotEmpty()) {
Log.w(tag, "Unknown talk directive keys (count=${parsed.unknownKeys.size})")
// Optionally emit redacted keys for debugging:
// val safe = parsed.unknownKeys.map { it.take(32).replace(Regex("[^a-zA-Z0-9_\\-]"), "?") }
}If you have telemetry/crash reporting, ensure it does not receive raw assistant text or directive metadata containing attacker-controlled content.
15. 🔵 PermissionRequester shows Settings AlertDialog from non-main thread (UI thread violation crash)
| Property | Value |
|---|---|
| Severity | Low |
| CWE | CWE-248 |
| Location | apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt:76-114 |
Description
PermissionRequester.requestIfMissing() may be called from background dispatchers (e.g., ScreenRecordManager.ensureMicPermission() runs under Dispatchers.Default). When permissions are denied with “Don’t ask again”, it calls showSettingsDialog() which directly creates and shows an AlertDialog and calls activity.startActivity(...) without switching to the main thread.
On Android, UI operations (including AlertDialog.show()) must occur on the main thread; invoking this from a background thread can trigger runtime exceptions (e.g., CalledFromWrongThreadException, Can't create handler inside thread...) leading to an app crash (DoS / crash loop).
Vulnerable code:
if (denied.isNotEmpty()) {
showSettingsDialog(denied)
}
private fun showSettingsDialog(permissions: List<String>) {
AlertDialog.Builder(activity)
...
.show()
}Recommendation
Run all dialog/show and startActivity(...) calls on the main thread, and ideally guard against invalid activity lifecycle state.
Example fix:
private suspend fun showSettingsDialog(permissions: List<String>) =
withContext(Dispatchers.Main.immediate) {
if (activity.isFinishing || activity.isDestroyed) return@withContext
AlertDialog.Builder(activity)
.setTitle("Enable permission in Settings")
.setMessage(buildSettingsMessage(permissions))
.setPositiveButton("Open Settings") { _, _ ->
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", activity.packageName, null),
)
activity.startActivity(intent)
}
.setNegativeButton("Cancel", null)
.show()
}Then in requestIfMissing:
if (denied.isNotEmpty()) showSettingsDialog(denied)Analyzed PR: #38712 at commit 6a2bb40
Last updated on: 2026-03-07T11:00:36Z
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 43e6c35012
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| gradle.startParameter.taskNames.any { taskName -> | ||
| taskName.contains("Release", ignoreCase = true) || | ||
| Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName) | ||
| } |
There was a problem hiding this comment.
Scope release-signing guard to app release tasks
The new wantsAndroidReleaseBuild predicate treats any task path ending in assemble/bundle as a release build, so commands like :benchmark:assemble (or other non-app module assemble tasks) will still trip the top-level error(...) and require keystore secrets that are unrelated to that task. Because this check runs during configuration in app/build.gradle.kts, it can block benchmark/test workflows on machines that do not have release signing properties configured.
Useful? React with 👍 / 👎.
43e6c35 to
6a2bb40
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6a2bb400e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| defaultConfig { | ||
| applicationId = "ai.openclaw.android" | ||
| applicationId = "ai.openclaw.app" |
There was a problem hiding this comment.
Align Android launch command with new applicationId
Changing applicationId to ai.openclaw.app without updating the root android:run script leaves the default dev launch flow broken: package.json still runs adb shell am start -n ai.openclaw.android/.MainActivity (line 223), so after :app:installDebug it tries to start a component that no longer exists under the renamed package. This regression is introduced by the package rename and will cause pnpm android:run to fail until the script target is updated.
Useful? React with 👍 / 👎.
* main: (45 commits) chore: update dependencies except carbon test(memory): make mcporter EINVAL retry test deterministic refactor(extensions): reuse shared helper primitives refactor(core): extract shared dedup helpers fix(ci): re-enable detect-secrets on main docs: reorder 2026.3.7 changelog highlights chore: bump version to 2026.3.7 fix(android): align run command with app id docs: add changelog entry for Android package rename (openclaw#38712) fix(android): rename app package to ai.openclaw.app fix(gateway): stop stale-socket restarts before first event (openclaw#38643) fix(gateway): skip stale-socket restarts for Telegram polling (openclaw#38405) fix(gateway): invalidate bootstrap cache on session rollover (openclaw#38535) docs: update changelog for reply media delivery (openclaw#38572) fix: contain final reply media normalization failures fix: contain block reply media failures fix: normalize reply media paths Fix owner-only auth and overlapping skill env regressions (openclaw#38548) fix(feishu): disable block streaming to prevent silent reply drops (openclaw#38422) fix: suppress ACP NO_REPLY fragments in console output (openclaw#38436) ...
(cherry picked from commit 2018d8a)
(cherry picked from commit 2018d8a)
Summary
ai.openclaw.appValidation
cd apps/android && ./gradlew clean :app:testDebugUnitTest :benchmark:assemble :app:bundleRelease