diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa29c16ee..b9c5e466ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr. - Security/Onboarding: clarify multi-user DM isolation remediation with explicit `openclaw config set session.dmScope ...` commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin. - Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability. +- Android/Nodes: harden `app.update` by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93. - Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u. - Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive. - Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index e30c1bc76b..4bd44b8efd 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -29,16 +29,11 @@ android { } } - signingConfigs { - getByName("debug") - } - buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - signingConfig = signingConfigs.getByName("debug") } debug { isMinifyEnabled = false diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index c319afdbf1..51daeff5ab 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -91,6 +91,7 @@ class NodeRuntime(context: Context) { val discoveryStatusText: StateFlow = discovery.statusText private val identityStore = DeviceIdentityStore(appContext) + private var connectedEndpoint: GatewayEndpoint? = null private val cameraHandler: CameraHandler = CameraHandler( appContext = appContext, @@ -110,6 +111,7 @@ class NodeRuntime(context: Context) { private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler( appContext = appContext, + connectedEndpoint = { connectedEndpoint }, ) private val locationHandler: LocationHandler = LocationHandler( @@ -200,7 +202,6 @@ class NodeRuntime(context: Context) { private var nodeConnected = false private var operatorStatusText: String = "Offline" private var nodeStatusText: String = "Offline" - private var connectedEndpoint: GatewayEndpoint? = null private val operatorSession = GatewaySession( diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt index 840c34fdf6..7472544d31 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt @@ -5,32 +5,107 @@ import android.content.Context import android.content.Intent import ai.openclaw.android.InstallResultReceiver import ai.openclaw.android.MainActivity +import ai.openclaw.android.gateway.GatewayEndpoint import ai.openclaw.android.gateway.GatewaySession +import java.io.File +import java.net.URI +import java.security.MessageDigest +import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +private val SHA256_HEX = Regex("^[a-fA-F0-9]{64}$") + +internal data class AppUpdateRequest( + val url: String, + val expectedSha256: String, +) + +internal fun parseAppUpdateRequest(paramsJson: String?, connectedHost: String?): AppUpdateRequest { + val params = + try { + paramsJson?.let { Json.parseToJsonElement(it).jsonObject } + } catch (_: Throwable) { + throw IllegalArgumentException("params must be valid JSON") + } ?: throw IllegalArgumentException("missing 'url' parameter") + + val urlRaw = + params["url"]?.jsonPrimitive?.content?.trim().orEmpty() + .ifEmpty { throw IllegalArgumentException("missing 'url' parameter") } + val sha256Raw = + params["sha256"]?.jsonPrimitive?.content?.trim().orEmpty() + .ifEmpty { throw IllegalArgumentException("missing 'sha256' parameter") } + if (!SHA256_HEX.matches(sha256Raw)) { + throw IllegalArgumentException("invalid 'sha256' parameter (expected 64 hex chars)") + } + + val uri = + try { + URI(urlRaw) + } catch (_: Throwable) { + throw IllegalArgumentException("invalid 'url' parameter") + } + val scheme = uri.scheme?.lowercase(Locale.US).orEmpty() + if (scheme != "https") { + throw IllegalArgumentException("url must use https") + } + if (!uri.userInfo.isNullOrBlank()) { + throw IllegalArgumentException("url must not include credentials") + } + val host = uri.host?.lowercase(Locale.US) ?: throw IllegalArgumentException("url host required") + val connectedHostNormalized = connectedHost?.trim()?.lowercase(Locale.US).orEmpty() + if (connectedHostNormalized.isNotEmpty() && host != connectedHostNormalized) { + throw IllegalArgumentException("url host must match connected gateway host") + } + + return AppUpdateRequest( + url = uri.toASCIIString(), + expectedSha256 = sha256Raw.lowercase(Locale.US), + ) +} + +internal fun sha256Hex(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (read == 0) continue + digest.update(buffer, 0, read) + } + } + val out = StringBuilder(64) + for (byte in digest.digest()) { + out.append(String.format(Locale.US, "%02x", byte)) + } + return out.toString() +} class AppUpdateHandler( private val appContext: Context, + private val connectedEndpoint: () -> GatewayEndpoint?, ) { fun handleUpdate(paramsJson: String?): GatewaySession.InvokeResult { try { - val url = paramsJson?.let { raw -> + val updateRequest = try { - Json.parseToJsonElement(raw).jsonObject["url"]?.jsonPrimitive?.content - } catch (e: Exception) { - null + parseAppUpdateRequest(paramsJson, connectedEndpoint()?.host) + } catch (err: IllegalArgumentException) { + return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: ${err.message ?: "invalid app.update params"}", + ) } - } ?: return GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = "INVALID_REQUEST: missing 'url' parameter" - ) + val url = updateRequest.url + val expectedSha256 = updateRequest.expectedSha256 android.util.Log.w("openclaw", "app.update: downloading from $url") @@ -125,6 +200,25 @@ class AppUpdateHandler( } android.util.Log.w("openclaw", "app.update: downloaded ${file.length()} bytes") + val actualSha256 = sha256Hex(file) + if (actualSha256 != expectedSha256) { + android.util.Log.e( + "openclaw", + "app.update: sha256 mismatch expected=$expectedSha256 actual=$actualSha256", + ) + file.delete() + notifManager.cancel(notifId) + notifManager.notify( + notifId, + android.app.Notification.Builder(appContext, channelId) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle("Update Failed") + .setContentIntent(launchPi) + .setContentText("SHA-256 mismatch") + .build(), + ) + return@launch + } // Verify file is a valid APK (basic check: ZIP magic bytes) val magic = file.inputStream().use { it.read().toByte() to it.read().toByte() } @@ -189,6 +283,7 @@ class AppUpdateHandler( return GatewaySession.InvokeResult.ok(buildJsonObject { put("status", "downloading") put("url", url) + put("sha256", expectedSha256) }.toString()) } catch (err: Throwable) { android.util.Log.e("openclaw", "app.update: error", err) diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt new file mode 100644 index 0000000000..743ed92c6d --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt @@ -0,0 +1,65 @@ +package ai.openclaw.android.node + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class AppUpdateHandlerTest { + @Test + fun parseAppUpdateRequest_acceptsHttpsWithMatchingHost() { + val req = + parseAppUpdateRequest( + paramsJson = + """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", + connectedHost = "gw.example.com", + ) + + assertEquals("https://gw.example.com/releases/openclaw.apk", req.url) + assertEquals("a".repeat(64), req.expectedSha256) + } + + @Test + fun parseAppUpdateRequest_rejectsNonHttps() { + assertThrows(IllegalArgumentException::class.java) { + parseAppUpdateRequest( + paramsJson = """{"url":"http://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", + connectedHost = "gw.example.com", + ) + } + } + + @Test + fun parseAppUpdateRequest_rejectsHostMismatch() { + assertThrows(IllegalArgumentException::class.java) { + parseAppUpdateRequest( + paramsJson = """{"url":"https://evil.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", + connectedHost = "gw.example.com", + ) + } + } + + @Test + fun parseAppUpdateRequest_rejectsInvalidSha256() { + assertThrows(IllegalArgumentException::class.java) { + parseAppUpdateRequest( + paramsJson = """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"bad"}""", + connectedHost = "gw.example.com", + ) + } + } + + @Test + fun sha256Hex_computesExpectedDigest() { + val tmp = File.createTempFile("openclaw-update-hash", ".bin") + try { + tmp.writeText("hello", Charsets.UTF_8) + assertEquals( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + sha256Hex(tmp), + ) + } finally { + tmp.delete() + } + } +} diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts index c3ef541122..63e1b1a4da 100644 --- a/src/cli/nodes-camera.test.ts +++ b/src/cli/nodes-camera.test.ts @@ -1,12 +1,13 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { cameraTempPath, parseCameraClipPayload, parseCameraSnapPayload, writeBase64ToFile, + writeUrlToFile, } from "./nodes-camera.js"; describe("nodes camera helpers", () => { @@ -61,4 +62,45 @@ describe("nodes camera helpers", () => { await expect(fs.readFile(out, "utf8")).resolves.toBe("hi"); await fs.rm(dir, { recursive: true, force: true }); }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("writes url payload to file", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("url-content", { status: 200 })), + ); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const out = path.join(dir, "x.bin"); + try { + await writeUrlToFile(out, "https://example.com/clip.mp4"); + await expect(fs.readFile(out, "utf8")).resolves.toBe("url-content"); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("rejects non-https url payload", async () => { + await expect(writeUrlToFile("/tmp/ignored", "http://example.com/x.bin")).rejects.toThrow( + /only https/i, + ); + }); + + it("rejects oversized content-length for url payload", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("tiny", { + status: 200, + headers: { "content-length": String(999_999_999) }, + }), + ), + ); + await expect(writeUrlToFile("/tmp/ignored", "https://example.com/huge.bin")).rejects.toThrow( + /exceeds max/i, + ); + }); }); diff --git a/src/cli/nodes-camera.ts b/src/cli/nodes-camera.ts index cf1cc41c70..ef06623a77 100644 --- a/src/cli/nodes-camera.ts +++ b/src/cli/nodes-camera.ts @@ -4,6 +4,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { resolveCliName } from "./cli-name.js"; +const MAX_CAMERA_URL_DOWNLOAD_BYTES = 250 * 1024 * 1024; + export type CameraFacing = "front" | "back"; export type CameraSnapPayload = { @@ -84,13 +86,62 @@ export async function writeUrlToFile(filePath: string, url: string) { if (parsed.protocol !== "https:") { throw new Error(`writeUrlToFile: only https URLs are allowed, got ${parsed.protocol}`); } + const res = await fetch(url); if (!res.ok) { throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`); } - const buf = Buffer.from(await res.arrayBuffer()); - await fs.writeFile(filePath, buf); - return { path: filePath, bytes: buf.length }; + + const contentLengthRaw = res.headers.get("content-length"); + const contentLength = contentLengthRaw ? Number.parseInt(contentLengthRaw, 10) : undefined; + if ( + typeof contentLength === "number" && + Number.isFinite(contentLength) && + contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES + ) { + throw new Error( + `writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`, + ); + } + + const body = res.body; + if (!body) { + throw new Error(`failed to download ${url}: empty response body`); + } + + const fileHandle = await fs.open(filePath, "w"); + let bytes = 0; + let thrown: unknown; + try { + const reader = body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value || value.byteLength === 0) { + continue; + } + bytes += value.byteLength; + if (bytes > MAX_CAMERA_URL_DOWNLOAD_BYTES) { + throw new Error( + `writeUrlToFile: downloaded ${bytes} bytes, exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`, + ); + } + await fileHandle.write(value); + } + } catch (err) { + thrown = err; + } finally { + await fileHandle.close(); + } + + if (thrown) { + await fs.unlink(filePath).catch(() => {}); + throw thrown; + } + + return { path: filePath, bytes }; } export async function writeBase64ToFile(filePath: string, base64: string) { diff --git a/src/cli/program.nodes-media.e2e.test.ts b/src/cli/program.nodes-media.e2e.test.ts index ade91084dd..563b6c8296 100644 --- a/src/cli/program.nodes-media.e2e.test.ts +++ b/src/cli/program.nodes-media.e2e.test.ts @@ -468,12 +468,13 @@ describe("cli program (nodes media)", () => { beforeAll(() => { originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn(async () => ({ - ok: true, - status: 200, - statusText: "OK", - arrayBuffer: async () => new TextEncoder().encode("url-content").buffer, - })) as unknown as typeof globalThis.fetch; + globalThis.fetch = vi.fn( + async () => + new Response("url-content", { + status: 200, + headers: { "content-length": String("11") }, + }), + ) as unknown as typeof globalThis.fetch; }); afterAll(() => {