fix: harden android update + camera url handling (openclaw#13541) thanks @smartprogrammer93

This commit is contained in:
Peter Steinberger
2026-02-13 16:46:00 +01:00
parent 7de4efb3e6
commit 9c179c9c31
8 changed files with 277 additions and 26 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -91,6 +91,7 @@ class NodeRuntime(context: Context) {
val discoveryStatusText: StateFlow<String> = 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(

View File

@@ -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)

View File

@@ -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()
}
}
}

View File

@@ -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,
);
});
});

View File

@@ -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) {

View File

@@ -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(() => {