mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix: harden android update + camera url handling (openclaw#13541) thanks @smartprogrammer93
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user