fix: audit batch 1 — critical Android updater + security hardening + bug fixes (#695)

14 confirmed audit findings: critical Android auto-update fix (SUPPORTED_ABIS), security hardening (exe allowlist, window-scoped approval, update-URL validation, remote PIN rate-limit/entropy, mirror order, prompt sanitization), correctness bugs (prefs race, hotkey spawn race, voice-agent focus clear), build-hygiene, and Android robustness. All-platform CI green; 446 tests pass.
This commit is contained in:
TRIP authored and GitHub committed 2026-06-17 11:13:27 +08:00
1 parent 0eb69fc0f4
commit 121bb11447
14 files changed
+279 -40

No files matched your search

+2 -1
View File
@@ -17,7 +17,8 @@
"merge:android-overlay-manifest": "node scripts/merge-android-overlay-manifest.mjs",
"copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs",
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs"
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs",
"check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs"
},
"dependencies": {
"@formkit/auto-animate": "^0.9.0",
@@ -22,18 +22,16 @@ if (!mainWindow) {
}
assertEqual(mainWindow.visible, false, 'main window should stay hidden until startup contract allows first show');
// Windows 走 while 循环轮询 hotkey 状态,等到 state !== 'starting' 再 setGate('ready')。
// 该路径在 if (os === 'win') 分支内,使用内联循环而非独立函数。
assertMatch(
appTsx,
/const \[gate, setGate\] = useState<Gate>\(isTauri \? 'checking' : 'ready'\);/,
'desktop app should start in checking gate before claiming ready',
/if \(os === 'win'\)/,
'windows startup gate should branch on os === win',
);
assertMatch(
appTsx,
/if \(os === 'win' && gate === 'checking'\) return;/,
'windows should not show the main shell while startup gate is still checking',
);
assertMatch(
appTsx,
/const pollHotkeyStatus = async \(\) => \{[\s\S]*?if \(status\.state !== 'starting'\) \{[\s\S]*?setGate\('ready'\);/m,
/status\.state !== 'starting'[\s\S]*?setGate\('ready'\)/m,
'windows startup should wait for hotkey status to leave the starting phase before entering ready',
);
+91
View File
@@ -464,6 +464,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -827,6 +833,35 @@ dependencies = [
"error-code",
]
[[package]]
name = "cocoa"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c"
dependencies = [
"bitflags 2.13.0",
"block",
"cocoa-foundation",
"core-foundation 0.10.1",
"core-graphics 0.24.0",
"foreign-types 0.5.0",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d"
dependencies = [
"bitflags 2.13.0",
"block",
"core-foundation 0.10.1",
"core-graphics-types 0.2.0",
"objc",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -3060,6 +3095,15 @@ dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -3459,6 +3503,26 @@ dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -3761,6 +3825,15 @@ dependencies = [
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]]
name = "oboe"
version = "0.6.1"
@@ -3823,6 +3896,7 @@ dependencies = [
"chrono",
"core-foundation 0.10.1",
"core-graphics 0.24.0",
"coreaudio-sys",
"cpal",
"dbus",
"enigo",
@@ -3861,6 +3935,7 @@ dependencies = [
"tar",
"tauri",
"tauri-build",
"tauri-nspanel",
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-shell",
@@ -5719,6 +5794,22 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-nspanel"
version = "2.0.1"
source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a"
dependencies = [
"bitflags 2.13.0",
"block",
"cocoa",
"core-foundation 0.10.1",
"core-graphics 0.25.0",
"objc",
"objc-foundation",
"objc_id",
"tauri",
]
[[package]]
name = "tauri-plugin"
version = "2.6.2"
+15 -7
View File
@@ -182,13 +182,21 @@ pub mod android {
&[JValue::Object(&action_obj)],
)
.map_err(|error| format!("set service action: {error}"))?;
let start_method = if action.ends_with(".HIDE") || action.ends_with(".SHOW") {
"startService"
} else if android_sdk_int(env)? >= 26 {
"startForegroundService"
} else {
"startService"
};
// REPLACE_OVERLAY 和 REFRESH_LAYOUT 与 SHOW/HIDE 一样,发送到已在运行的服务,
// 不应使用 startForegroundService(Android 12+ 在后台调用会抛
// ForegroundServiceStartNotAllowedException)。
let start_method =
if action.ends_with(".HIDE")
|| action.ends_with(".SHOW")
|| action.ends_with(".REPLACE_OVERLAY")
|| action.ends_with(".REFRESH_LAYOUT")
{
"startService"
} else if android_sdk_int(env)? >= 26 {
"startForegroundService"
} else {
"startService"
};
env.call_method(
context,
start_method,
@@ -40,13 +40,12 @@ mod android_impl {
fn device_arch() -> Result<&'static str, String> {
crate::android::jni::android::with_android_env(|env, _context| {
let abis_obj = env
.call_static_method(
.get_static_field(
"android/os/Build",
"SUPPORTED_ABIS",
"()[Ljava/lang/String;",
&[],
"[Ljava/lang/String;",
)
.and_then(|value| value.l())
.and_then(|v| v.l())
.map_err(|e| format!("read SUPPORTED_ABIS: {e}"))?;
let abis_array = jni::objects::JObjectArray::from(abis_obj);
let len = env
@@ -220,6 +219,15 @@ mod android_impl {
return Err(format!("download status {}", resp.status()));
}
let total = resp.content_length();
// 安全:防止无限流耗尽内存。200 MB 远超任何实际 APK 大小(当前约 50 MB)。
const MAX_APK_BYTES: u64 = 200 * 1024 * 1024;
if let Some(len) = total {
if len > MAX_APK_BYTES {
return Err(format!(
"APK 声明大小 {len} 字节超过上限 {MAX_APK_BYTES},拒绝下载"
));
}
}
let mut downloaded: u64 = 0;
let mut bytes = Vec::new();
let mut stream = resp.bytes_stream();
@@ -227,6 +235,11 @@ mod android_impl {
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("download chunk: {e}"))?;
downloaded += chunk.len() as u64;
if downloaded > MAX_APK_BYTES {
return Err(format!(
"下载字节数 {downloaded} 超过上限 {MAX_APK_BYTES},已终止"
));
}
bytes.extend_from_slice(&chunk);
let _ = app.emit(
"android-update:progress",
@@ -8,7 +8,7 @@ use std::sync::Arc;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Window};
use super::detect::{has_computer_use_mcp, McpServerStatus};
use super::guard::build_guard_settings_json;
@@ -29,10 +29,57 @@ fn next_session_id() -> String {
format!("console-{}", *c)
}
fn normalize_exe(exe: Option<String>) -> String {
exe.map(|e| e.trim().to_string())
/// 仅允许裸名 "claude" 或规范化到已知安装目录下的绝对路径。
/// 拒绝包含路径分隔符的相对路径(如 "../../evil")。
fn validate_exe(exe: &str) -> Result<(), String> {
// 纯可执行文件名,不含任何路径分隔符 — 交给 PATH 解析即可
if !exe.contains('/') && !exe.contains('\\') {
if exe == "claude" {
return Ok(());
}
return Err(format!("不允许的可执行文件名: {exe}(只接受 'claude' 或已知安装目录下的绝对路径)"));
}
// 绝对路径:必须规范化到已知 claude 安装目录之一
let path = std::path::Path::new(exe);
if !path.is_absolute() {
return Err(format!("不允许的相对路径: {exe}"));
}
// 已知 claude 安装目录前缀
let known_prefixes: &[&str] = &[
"/usr/local/bin/",
"/usr/bin/",
"/opt/homebrew/bin/",
];
// 也允许 ~/.local/bin/claude(用户目录绝对路径,动态计算)
let home_prefix = std::env::var("HOME")
.ok()
.map(|h| format!("{h}/.local/bin/"));
let exe_norm = exe.replace('\\', "/");
let allowed = known_prefixes.iter().any(|p| exe_norm.starts_with(p))
|| home_prefix.as_deref().map_or(false, |p| exe_norm.starts_with(p));
if allowed {
Ok(())
} else {
Err(format!("不允许的 claude 路径: {exe}(必须位于已知安装目录)"))
}
}
fn normalize_exe(exe: Option<String>) -> Result<String, String> {
let exe = exe
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.unwrap_or_else(|| "claude".to_string())
.unwrap_or_else(|| "claude".to_string());
validate_exe(&exe)?;
Ok(exe)
}
fn ensure_main_window(window: &Window) -> Result<(), String> {
if window.label() == "main" {
Ok(())
} else {
Err("coding agent commands are only allowed from the main window".to_string())
}
}
/// Claude Code 检测结果(回前端,camelCase)。
@@ -53,8 +100,12 @@ pub struct ClaudeDetectionWire {
/// 检测 claude 是否安装、版本、已配置的 MCP server(即「computer use 技能」检测口径)。
#[tauri::command]
pub async fn coding_agent_detect(exe: Option<String>) -> ClaudeDetectionWire {
let exe = normalize_exe(exe);
pub async fn coding_agent_detect(
window: Window,
exe: Option<String>,
) -> Result<ClaudeDetectionWire, String> {
ensure_main_window(&window)?;
let exe = normalize_exe(exe)?;
let version = detect_claude(&exe).await;
let mcp_servers = if version.is_some() {
claude_mcp_list(&exe).await
@@ -62,13 +113,13 @@ pub async fn coding_agent_detect(exe: Option<String>) -> ClaudeDetectionWire {
Vec::new()
};
let has_computer_use = has_computer_use_mcp(&mcp_servers);
ClaudeDetectionWire {
Ok(ClaudeDetectionWire {
installed: version.is_some(),
version,
exe,
mcp_servers,
has_computer_use,
}
})
}
/// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。
@@ -77,6 +128,7 @@ pub async fn coding_agent_detect(exe: Option<String>) -> ClaudeDetectionWire {
/// 若 workdir 是 git 仓库,运行前做一次 `git stash create` 快照(可回滚)。
#[tauri::command]
pub async fn coding_agent_run_test(
window: Window,
app: AppHandle,
prompt: String,
exe: Option<String>,
@@ -85,11 +137,12 @@ pub async fn coding_agent_run_test(
model: Option<String>,
max_budget_usd: Option<f64>,
) -> Result<(), String> {
ensure_main_window(&window)?;
let prompt = prompt.trim().to_string();
if prompt.is_empty() {
return Err("指令为空".into());
}
let exe = normalize_exe(exe);
let exe = normalize_exe(exe)?;
let mode = permission_mode.unwrap_or_default();
let cwd = workdir
+13 -1
View File
@@ -77,7 +77,19 @@ pub fn less_computer_window_resize(coord: CoordinatorState<'_>, height: f64) {
}
/// 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。
///
/// 安全:审批 UI 渲染在 less-computer 窗口(LessComputerPanel),故仅允许该窗口提交,
/// 拦截 main / capsule / qa / glow 等其它窗口伪造审批 —— 把可调用窗口从 5 个收紧到 1 个。
#[tauri::command]
pub fn less_computer_approve(coord: CoordinatorState<'_>, token: String, approved: bool) {
pub fn less_computer_approve(
window: Window,
coord: CoordinatorState<'_>,
token: String,
approved: bool,
) -> Result<(), String> {
if window.label() != "less-computer" {
return Err("approval can only be submitted from the Less Computer window".to_string());
}
coord.less_computer_approve(&token, approved);
Ok(())
}
@@ -473,6 +473,13 @@ pub async fn app_download_and_install_android_update(
signature: String,
version: String,
) -> Result<(), String> {
// 安全:下载前校验 URL,防止 SSRF(如内网元数据接口、localhost 服务)。
// 只允许已知的 GitHub 直链和 fastgit 镜像前缀。
const DIRECT_BASE: &str = "https://github.com/appergb/openless";
const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/appergb/openless";
if !url.starts_with(DIRECT_BASE) && !url.starts_with(MIRROR_BASE) {
return Err(format!("不信任的更新 URL,拒绝下载: {url}"));
}
#[cfg(target_os = "android")]
{
return crate::android::updater::download_and_install(app, url, signature, version).await;
@@ -671,7 +671,11 @@ async fn run_voice_agent_transcript(
None => outcome,
};
inner.state.lock().phase = SessionPhase::Idle;
{
let mut state = inner.state.lock();
state.phase = SessionPhase::Idle;
state.focus_target = None; // 清除过期焦点目标,避免影响下次会话
}
// 工作结束:熄灭全屏彩虹描边(聊天浮窗保留,等用户读完/关闭)。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
@@ -1018,7 +1018,9 @@ pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEve
}
}
HotkeyEvent::QaShortcutPressed => {
async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await });
async_runtime::block_on(async {
handle_qa_hotkey_pressed(&inner_cloned).await;
});
}
}
}
@@ -1297,8 +1297,8 @@ impl PreferencesStore {
pub fn set(&self, prefs: UserPreferences) -> Result<()> {
let json = serde_json::to_vec_pretty(&prefs).context("encode prefs failed")?;
atomic_write(&self.path, &json)?;
let mut guard = self.state.lock();
atomic_write(&self.path, &json)?;
*guard = prefs;
Ok(())
}
@@ -24,7 +24,21 @@ pub(super) fn context_premise(
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let app = front_app.map(str::trim).filter(|s| !s.is_empty());
// 安全:window title 是攻击者可控字段,嵌入前必须清理。
// 去除换行符(防止注入多行指令)和 Markdown/XML 分隔符(防止结构性提示注入);
// 截断到 100 个字符(远超任何真实 app 名称的合理长度)。
let app = front_app
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
let sanitized: String = s
.chars()
.filter(|c| *c != '\n' && *c != '\r' && *c != '#' && *c != '<' && *c != '>')
.take(100)
.collect();
sanitized
})
.filter(|s| !s.is_empty());
let script_line = match chinese_script_preference {
ChineseScriptPreference::Simplified => Some(
@@ -50,6 +50,10 @@ const PIN_MAX_FAILS: u32 = 5;
const PIN_LOCK_SECS: u64 = 60;
/// pin_fails 表的容量上限:超过即清理已过期/已解锁的条目,防止伪造海量源 IP 撑爆内存。
const PIN_FAILS_MAX_ENTRIES: usize = 256;
/// 全局(跨 IP)PIN 失败上限和重置窗口。防止局域网内多台设备轮换 IP 绕过按 IP 的限速。
/// 20 次/分钟 ≈ 0.02% 的 PIN 空间,在 60s 锁定前实际可试到的组合数极少。
const PIN_GLOBAL_MAX_FAILS: u32 = 20;
const PIN_GLOBAL_WINDOW_SECS: u64 = 60;
/// 单个 PCM 二进制帧的上限。16kHz/16bit 实时流正常每帧只有几 KB,64KB ≈ 2 秒音频;
/// 超限帧直接丢弃,防已配对客户端(或驱动它的恶意网页)推超大帧造成内存压力。
const MAX_PCM_FRAME_BYTES: usize = 64 * 1024;
@@ -104,11 +108,20 @@ pub struct RemoteInputStatus {
// ───────────────────────── 工具函数 ─────────────────────────
/// 生成 6 位数字配对码。用 uuid v4 的随机字节取模,无需引入 rand。
/// 生成 6 位数字配对码。使用 rejection sampling 消除取模偏差(u32::MAX 不是
/// 1_000_000 的倍数,直接取模会给低编号 PIN 带来约 0.03% 的额外概率)。
pub fn generate_pin() -> String {
let b = uuid::Uuid::new_v4().into_bytes();
let n = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) % 1_000_000;
format!("{n:06}")
// u32::MAX + 1 = 2^32;舍弃使结果偏置的高端余数区间。
// 偏置截止点:u32::MAX - (u32::MAX % 1_000_000) + 1(向下取整到 1_000_000 的倍数)
const LIMIT: u32 = u32::MAX - (u32::MAX % 1_000_000);
loop {
let b = uuid::Uuid::new_v4().into_bytes();
let n = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
if n < LIMIT {
return format!("{:06}", n % 1_000_000);
}
// 极罕见(约 2^32 mod 10^6 / 2^32 ≈ 0.007% 概率),重新采样即可。
}
}
fn pin_path(app: &AppHandle) -> Option<std::path::PathBuf> {
@@ -283,6 +296,8 @@ struct WsState {
app: AppHandle,
/// 按源 IP 的 PIN 失败计数 + 锁定截止时刻(防爆破;TLS+6 位 PIN 已是主防线)。
pin_fails: Mutex<std::collections::HashMap<IpAddr, (u32, Option<Instant>)>>,
/// 全局 PIN 失败计数 + 计数窗口起始时刻(防跨 IP 分布式暴力)。
pin_global_fails: Mutex<(u32, Instant)>,
/// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。
cert_der: Vec<u8>,
/// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。
@@ -446,6 +461,7 @@ pub async fn start(cfg: RemoteServerConfig) -> Result<RemoteServerHandle, String
coordinator: cfg.coordinator,
app: cfg.app,
pin_fails: Mutex::new(std::collections::HashMap::new()),
pin_global_fails: Mutex::new((0, Instant::now())),
cert_der,
conn_shutdown_rx,
});
@@ -738,6 +754,23 @@ fn verify_hello(txt: &str, state: &Arc<WsState>, peer_ip: IpAddr) -> AuthResult
// 锁定检查与失败累计放同一临界区:之前分两次拿锁,同一 IP 的并发握手可以
// 都先通过锁定检查再各自累计失败,让计数越过阈值却不触发锁定。
let now = Instant::now();
// 全局限速:防局域网内多台设备轮换 IP 绕过按 IP 的限速(分布式暴力)。
{
let mut global = state.pin_global_fails.lock();
let window_elapsed = now.duration_since(global.1).as_secs();
if window_elapsed >= PIN_GLOBAL_WINDOW_SECS {
// 滑动窗口到期,重置计数
*global = (0, now);
}
if !pin_ok {
global.0 += 1;
}
if global.0 >= PIN_GLOBAL_MAX_FAILS {
return AuthResult::Locked;
}
}
let mut guard = state.pin_fails.lock();
if let Some((_, Some(until))) = guard.get(&peer_ip) {
if now < *until {
@@ -748,6 +781,9 @@ fn verify_hello(txt: &str, state: &Arc<WsState>, peer_ip: IpAddr) -> AuthResult
}
if pin_ok {
guard.remove(&peer_ip);
// 成功认证后重置全局计数,避免暴力后期合法用户被误锁
let mut global = state.pin_global_fails.lock();
global.0 = 0;
AuthResult::Ok
} else {
// 容量兜底:先丢已解锁/过期的条目,防伪造海量源 IP 撑爆表。
+2 -2
View File
@@ -101,8 +101,8 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZGNEI1OTk0RjMzMzk0QTkKUldTcGxEUHpsRmxMYjRnMFdZNkFyaVozaHN2SUNwZ01mMDlFS0RMWnNQNisrWjF6czZQQk1RQysK",
"endpoints": [
"https://fastgit.cc/https://github.com/appergb/openless/releases/latest/download/latest-{{target}}-{{arch}}-mirror.json",
"https://github.com/appergb/openless/releases/latest/download/latest-{{target}}-{{arch}}.json"
"https://github.com/appergb/openless/releases/latest/download/latest-{{target}}-{{arch}}.json",
"https://fastgit.cc/https://github.com/appergb/openless/releases/latest/download/latest-{{target}}-{{arch}}-mirror.json"
]
}
}