Skip to content

fix(android): rename app package to ai.openclaw.app#38712

Merged
obviyus merged 2 commits intomainfrom
fix/android-app-package-id
Mar 7, 2026
Merged

fix(android): rename app package to ai.openclaw.app#38712
obviyus merged 2 commits intomainfrom
fix/android-app-package-id

Conversation

@obviyus
Copy link
Copy Markdown
Contributor

@obviyus obviyus commented Mar 7, 2026

Summary

  • rename the Android app package/applicationId to ai.openclaw.app
  • move Android app, test, and benchmark Kotlin packages to match the new package path
  • update Android package references in benchmark and perf tooling

Validation

  • cd apps/android && ./gradlew clean :app:testDebugUnitTest :benchmark:assemble :app:bundleRelease

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Too many files changed for review. (118 files found, 100 file limit)

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 7, 2026

🔒 Aisle Security Analysis

We found 15 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Ed25519 private key stored unencrypted in filesDir and included in Android backups
2 🟠 High Unbounded Base64 and bitmap decoding allows remote memory-exhaustion DoS in chat rendering
3 🟠 High WebView JavaScript bridge exposed to arbitrary/untrusted origins (no navigation allowlist)
4 🟠 High TalkModeManager TTS plays gateway events when sessionKey is missing (cross-session privacy leak)
5 🟠 High Setup code parsing permits plaintext ws/http endpoints, risking credential exposure
6 🟠 High Unrestricted WebView navigation + remote JavaScript execution enables local resource access and data exfiltration
7 🟠 High Manual gateway connections can use plaintext WebSocket, exposing tokens/passwords
8 🟠 High Untrusted canvasHostUrl from gateway is loaded into WebView without scheme/host allowlisting (SSRF/remote content injection)
9 🟡 Medium Unbounded in-memory buffering in StreamingMediaDataSource can lead to OOM/DoS during long TTS streams
10 🟡 Medium App update URL host/scheme validation bypass via OkHttp redirects
11 🟡 Medium Remote-triggered arbitrary APK installation via app.update without package/signature validation
12 🟡 Medium Unvalidated canvasCapability inserted into scoped canvas URL enables path/query injection
13 🔵 Low Unbounded attachment ingestion reads entire selected image into memory before encoding (local memory-exhaustion DoS)
14 🔵 Low Sensitive data exposure via logging of unknown TalkDirective JSON keys
15 🔵 Low PermissionRequester shows Settings AlertDialog from non-main thread (UI thread violation crash)

1. 🟠 Ed25519 private key stored unencrypted in filesDir and included in Android backups

Property Value
Severity High
CWE CWE-922
Location apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt:10-121

Description

The 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:

  • DeviceIdentityStore writes privateKeyPkcs8Base64 to filesDir/openclaw/identity/device.json without encryption.
  • AndroidManifest.xml sets android:allowBackup="true" and points to backup rules.
  • res/xml/backup_rules.xml and res/xml/data_extraction_rules.xml explicitly include domain="file" path=".", which includes filesDir/** and therefore the identity JSON file.

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.

Recommendation

Prevent backup exposure and avoid storing the private key unencrypted.

  1. Exclude the identity file/directory from backups (or disable backups entirely):
<!-- 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" ... />
  1. Store private keys using Android Keystore (preferred) or encrypt the file at rest (e.g., androidx.security.crypto.EncryptedFile with a MasterKey). Example (file encryption):
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

Property Value
Severity High
CWE CWE-400
Location apps/android/app/src/main/java/ai/openclaw/app/ui/chat/Base64ImageState.kt:29-33

Description

The chat UI decodes and renders Base64-encoded images from remotely-supplied chat messages (attachments and inline markdown data:image/...;base64,...) without any size limits.

  • ChatController.parseHistory() parses gateway-provided JSON and stores content as ChatMessageContent.base64 (remote-controlled).
  • ChatMessageViews renders any non-text content part with a non-null base64 as an image.
  • ChatMarkdown supports inline data:image/...;base64,... image destinations in markdown, extracting an unbounded Base64 string.
  • rememberBase64ImageState() performs Base64.decode() and then BitmapFactory.decodeByteArray() with no cap on Base64 length and no bitmap downsampling / dimension limits.

Impact:

  • A malicious/compromised gateway (or assistant output routed through the gateway) can send extremely large Base64 strings that trigger huge allocations during Base64 decode and bitmap decode, causing OutOfMemoryError, UI freezes, and app crashes (denial of service).

Vulnerable code:

val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)

Recommendation

Add strict limits and safe decoding for untrusted Base64 inputs.

  1. Reject overly large Base64 strings before decoding (use a conservative cap):
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)
  1. Downsample bitmaps and enforce max dimensions using inJustDecodeBounds and inSampleSize:
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
  1. Consider enforcing message/attachment limits at the ingest point (e.g., when parsing history/events) so the UI never sees huge payloads.

3. 🟠 WebView JavaScript bridge exposed to arbitrary/untrusted origins (no navigation allowlist)

Property Value
Severity High
CWE CWE-749
Location apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt:48-126

Description

The app creates a WebView with JavaScript enabled and registers a @​JavascriptInterface bridge (openclawCanvasA2UIAction) without restricting which origins can call it.

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

  • CanvasScreen enables JavaScript and allows mixed content, then exposes a native bridge to any loaded page:
    • settings.javaScriptEnabled = true
    • settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
    • addJavascriptInterface(bridge, "openclawCanvasA2UIAction")
  • CanvasController.navigate(url) accepts an arbitrary URL string and loads it via WebView.loadUrl() with no scheme/host validation.
  • The bridge’s postMessage() forwards attacker-controlled JSON into NodeRuntime.handleCanvasA2UIActionFromWebView(), which (after minimal parsing) sends a nodeSession.sendNodeEvent(event="agent.request", ...) message to the gateway.

Concrete exploit scenario

  1. An attacker causes the canvas to load an attacker-controlled URL (e.g., via canvas.navigate from a compromised/malicious gateway/operator, or via an open redirect/redirect chain from a trusted page).
  2. The page runs:
    openclawCanvasA2UIAction.postMessage(JSON.stringify({
      userAction: { name: "anything", context: { /* attacker-controlled */ } }
    }))
  3. The Android app will forward this into an agent.request event. If the backend agent/workflow treats these requests as trusted user actions, this can lead to unauthorized downstream actions (e.g., initiating tool/invoke flows that can result in SMS/camera/screen actions).

Additional risk

  • MIXED_CONTENT_COMPATIBILITY_MODE allows HTTPS pages to load HTTP subresources, enabling network attackers (on hostile Wi‑Fi) to inject JavaScript into otherwise HTTPS pages and then call the exposed JS interface.

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 = ...)

Recommendation

Restrict 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 addJavascriptInterface for arbitrary web content. Prefer WebMessagePort / WebViewCompat.addWebMessageListener with an explicit allowedOriginRules list.

3) Disable mixed content. Use MIXED_CONTENT_NEVER_ALLOW unless there is a strong, documented need.

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 addJavascriptInterface must be kept, only add it after verifying the page origin (e.g., in onPageFinished) and remove it immediately when navigating away from the trusted origin.


4. 🟠 TalkModeManager TTS plays gateway events when sessionKey is missing (cross-session privacy leak)

Property Value
Severity High
CWE CWE-200
Location apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt:273-278

Description

TalkModeManager attempts to prevent cross-session TTS leaks by filtering gateway agent and chat events against mainSessionKey. However, the filter is not safe-by-default: when the incoming payload omits sessionKey, the code accepts the event and may speak it.

This matters because the gateway agent-event broadcaster can emit agent events without a sessionKey (it conditionally adds the field only when resolved), meaning the Android client may treat such events as belonging to the active session.

Impact when ttsOnAllResponses is enabled (e.g., user has voice screen/mic enabled):

  • Assistant output from other sessions/channels can be spoken aloud if sessionKey is missing or not reliably set.
  • This can result in inadvertent disclosure of private content to bystanders.

Vulnerable code (accepts events when sessionKey is null):

val eventSession = payload["sessionKey"]?.asStringOrNull()
val activeSession = mainSessionKey.ifBlank { "main" }
if (eventSession != null && eventSession != activeSession) return

The same pattern exists in handleGatewayEvent() for chat terminal events.

Recommendation

Make session filtering fail closed for TTS paths:

  • Require sessionKey to be present and match mainSessionKey before speaking.
  • Consider additionally binding speech to runIds initiated by this manager (defense-in-depth), but at minimum enforce strict session matching.

Example fix:

val eventSession = payload["sessionKey"]?.asStringOrNull()?.trim()
val activeSession = mainSessionKey.ifBlank { "main" }
if (eventSession.isNullOrEmpty() || eventSession != activeSession) return

Apply the same logic in both:

  • handleAgentStreamEvent(payloadJson)
  • handleGatewayEvent(event="chat") (before any ttsOnAllResponses speaking/stream-finishing logic)

5. 🟠 Setup code parsing permits plaintext ws/http endpoints, risking credential exposure

Property Value
Severity High
CWE CWE-319
Location apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt:76-82

Description

parseGatewayEndpoint() derives the TLS requirement directly from the user/QR-provided URL scheme and allows insecure ws/http endpoints.

  • If the setup code contains ws://... or http://..., tls becomes false.
  • That flag is later used to establish a non-TLS WebSocket connection (via GatewaySession, which chooses ws when TLS params are null).
  • The gateway authentication token/password is then transmitted during the connect RPC, potentially exposing secrets to on-path attackers (MITM) on the network.

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.

Recommendation

Enforce secure schemes for setup codes (and ideally for manual entry by default):

  • Reject any scheme not in an explicit allowlist (wss/https), or require an explicit “Allow insecure connection” user opt-in gated behind a warning.
  • Do not silently treat unknown schemes as TLS-enabled.

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 TLS

If you must support ws/http for development, gate it behind a debug build flag and a prominent UI warning, and avoid sending long-lived tokens/passwords over plaintext.


6. 🟠 Unrestricted WebView navigation + remote JavaScript execution enables local resource access and data exfiltration

Property Value
Severity High
CWE CWE-918
Location apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt:102-116

Description

The gateway-controlled invoke layer allows arbitrary URLs to be loaded into an in-app WebView and allows the gateway to run arbitrary JavaScript and obtain results/snapshots, with no scheme/origin allowlist.

This creates a concrete exfiltration path if the gateway/operator is compromised or untrusted:

  • Input (remote): paramsJson from the gateway provides a url for canvas.navigate / canvas.present.
  • Sink: the URL is used directly in WebView.loadUrl(...).
  • Additional sinks:
    • canvas.eval runs arbitrary JS via WebView.evaluateJavascript(...) and returns the result to the gateway.
    • canvas.snapshot captures the rendered page and returns a base64 image to the gateway.
  • Missing mitigations: no checks preventing navigation to dangerous schemes such as file://, content://, intent://, javascript: (or attacker-controlled http://), and WebView settings do not disable file/content access.

Impact examples:

  • A malicious gateway can navigate to content://... resources (leveraging the app's granted Android permissions) and then use canvas.eval/canvas.snapshot to exfiltrate sensitive data.
  • A malicious gateway can navigate to file://... resources and exfiltrate their rendered contents.

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:

  • InvokeDispatcher.handleInvoke parses the URL from remote params and calls canvas.navigate(url).
  • CanvasScreen enables JavaScript and sets mixed-content compatibility, without disabling file/content access.

Recommendation

Implement a strict navigation allowlist and harden WebView settings.

  1. Block dangerous schemes (and ideally enforce an allowlist of trusted origins):
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()
}
  1. Harden WebView settings in CanvasScreen (especially if any untrusted content might ever be loaded):
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 = false

If you must load local assets, consider using WebViewAssetLoader (serving assets over an https://appassets.androidplatform.net/... origin) so you can keep file access disabled.

  1. Consider disabling canvas.eval for production or restricting it to trusted A2UI pages only (e.g., gate by current origin + a capability flag).

7. 🟠 Manual gateway connections can use plaintext WebSocket, exposing tokens/passwords

Property Value
Severity High
CWE CWE-319
Location apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt:32-36

Description

The Android client allows manual connections without TLS (manualTls=false). In this mode the app connects using ws:// and sends authentication material (token/password) and other session data over an unencrypted channel.

  • ConnectionManager.resolveTlsParamsForEndpoint() returns null TLS params for manual endpoints when the user disables TLS
  • GatewaySession uses ws whenever tls == null
  • The initial connect RPC includes auth.token or auth.password, which are then transmitted in cleartext on ws://
  • The UI/flows make this easy to hit (e.g., onboarding defaults manualTls to false, and quick-fill chips set it to false), increasing the likelihood of accidental insecure use

This enables passive network attackers (same Wi‑Fi/LAN, captive portal, malicious router) to capture gateway credentials and potentially impersonate the device/operator.

Recommendation

Require transport security for any connection that may send secrets.

Suggested fixes (pick one or combine):

  1. Disallow non-TLS manual connections in production builds:
if (isManual && !manualTlsEnabled) {
  throw IllegalStateException("Manual connections must use TLS")
}
  1. If you must support plaintext for localhost/dev, gate it tightly:
  • Only allow manualTls=false when host is loopback (127.0.0.1, ::1, emulator bridge) and build is DEBUG.
  • Otherwise force TLS.
  1. Add a hard UI warning/confirmation and block sending token/password unless TLS is enabled:
if (tls == null && (!token.isNullOrBlank() || !password.isNullOrBlank())) {
  throw IllegalStateException("Refusing to send credentials without TLS")
}

Also update onboarding/quick-fill defaults so manualTls starts as true (secure-by-default).


8. 🟠 Untrusted canvasHostUrl from gateway is loaded into WebView without scheme/host allowlisting (SSRF/remote content injection)

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): canvasHostUrl from gateway RPC payload (connect)
  • Propagation: stored in GatewaySession.canvasHostUrl
  • Sink: A2UIHandler.resolveA2uiHostUrl() builds a URL and CanvasController.navigate() / WebView.loadUrl() loads it

Impact:

  • A malicious/spoofed gateway (or MITM on non-TLS connections) can provide a canvasHostUrl pointing 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 host is 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 uses https
  • 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 into StreamingMediaDataSource.append().
  • StreamingMediaDataSource stores each chunk in an ArrayList and increases totalSize, but does not drop data that has already been read by MediaPlayer.
  • 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:

  1. 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
  }
}
  1. 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"
    • host exactly matches the connected gateway host (consider also normalizing trailing dots / IDN via HttpUrl.host)
    • optionally restrict port to 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 url and sha256 (parseAppUpdateRequest), and the sha256 is not anchored to any local trust decision.
  • No verification that the APK’s packageName equals appContext.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_CERTIFICATES and 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: canvasCapability comes from the gateway RPC response node.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.resolveA2uiHostUrlCanvasController.navigateWebView.loadUrl), so rewriting to an unintended path can load unexpected web content.

Vulnerable code:

return scopedUrl.substring(0, capabilityStart) + capability + scopedUrl.substring(capabilityEnd)

Recommendation

Treat canvasCapability as an untrusted URL path segment.

  1. Validate it against a strict allowlist (e.g., base64url token) and reject anything else.
  2. 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 JpegSizeLimiter patterns 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

@openclaw-barnacle openclaw-barnacle bot added app: android App: android size: L maintainer Maintainer-authored PR labels Mar 7, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +20 to +23
gradle.startParameter.taskNames.any { taskName ->
taskName.contains("Release", ignoreCase = true) ||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@obviyus obviyus force-pushed the fix/android-app-package-id branch from 43e6c35 to 6a2bb40 Compare March 7, 2026 09:20
@obviyus obviyus merged commit 2018d8a into main Mar 7, 2026
8 of 9 checks passed
@obviyus obviyus deleted the fix/android-app-package-id branch March 7, 2026 09:21
@obviyus
Copy link
Copy Markdown
Contributor Author

obviyus commented Mar 7, 2026

Landed via temp rebase onto main.

  • Gate: cd apps/android && ./gradlew clean :app:testDebugUnitTest :benchmark:assemble :app:bundleRelease
  • Land commit: 6a2bb40
  • Merge commit: 2018d8a

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

mrosmarin added a commit to mrosmarin/openclaw that referenced this pull request Mar 7, 2026
* 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)
  ...
mcaxtr pushed a commit to mcaxtr/openclaw that referenced this pull request Mar 7, 2026
vincentkoc pushed a commit to BryanTegomoh/openclaw-fork that referenced this pull request Mar 8, 2026
Saitop pushed a commit to NomiciAI/openclaw that referenced this pull request Mar 8, 2026
jenawant pushed a commit to jenawant/openclaw that referenced this pull request Mar 10, 2026
dhoman pushed a commit to dhoman/chrono-claw that referenced this pull request Mar 11, 2026
senw-developers pushed a commit to senw-developers/va-openclaw that referenced this pull request Mar 17, 2026
V-Gutierrez pushed a commit to V-Gutierrez/openclaw-vendor that referenced this pull request Mar 17, 2026
alexey-pelykh pushed a commit to remoteclaw/remoteclaw that referenced this pull request Mar 20, 2026
alexey-pelykh pushed a commit to remoteclaw/remoteclaw that referenced this pull request Mar 20, 2026
@obviyus obviyus self-assigned this Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: android App: android maintainer Maintainer-authored PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant