From e8b024b6eca6d86da32388a3edeadfb72410f99a Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:46:28 +0800 Subject: [PATCH 01/15] fix(android): SUPPORTED_ABIS accessed via get_static_field not call_static_method android.os.Build.SUPPORTED_ABIS is a static final String[] field (API 21), not a static method. call_static_method threw NoSuchMethodError at runtime, causing device_arch() to always fail and the entire Android auto-update path to be silently broken on every device. CI-verified-only (Android cfg; macOS build still passes). --- openless-all/app/src-tauri/src/android/updater.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index 29e7f447..f57c0a99 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -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 From 36918cd6e98c806043149022679bf9bf7a55b656 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:47:03 +0800 Subject: [PATCH 02/15] fix(persistence): acquire Mutex before disk write in PreferencesStore::set Two concurrent callers could interleave: A writes prefs_A to disk, B writes prefs_B to disk, then they acquire the lock in reverse order. Result: disk and in-memory state diverge, silently rolling back settings after restart. Hold the lock across both atomic_write and the in-memory update so the two operations form a single critical section. --- openless-all/app/src-tauri/src/persistence.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index ab8a26dd..6c5acdd5 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -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(()) } From c3244253aad2d9212bc7dc30bb4cdd3893ea8e53 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:47:20 +0800 Subject: [PATCH 03/15] fix(coordinator): QaShortcutPressed uses block_on to prevent spawn race All other hotkey events (Pressed, Released, Cancelled) use block_on so the bridge thread serialises handlers. QaShortcutPressed used spawn, so the bridge looped immediately and could start dictation concurrently while handle_qa_hotkey_pressed was still running. Consistent block_on eliminates the panel_visible race against the next Pressed event. --- openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 9f9643df..a2cc853c 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1018,7 +1018,9 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { - async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); + async_runtime::block_on(async { + handle_qa_hotkey_pressed(&inner_cloned).await; + }); } } } From 2a1d2f81035b8905c52efedbca2022754335ab1b Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:47:41 +0800 Subject: [PATCH 04/15] fix(coordinator): clear focus_target in run_voice_agent_transcript The normal dictation path clears focus_target when returning to Idle, but run_voice_agent_transcript only set phase=Idle and left focus_target set. A stale focus_target from a completed voice agent session could cause the next dictation session to restore focus to a no-longer-valid window. --- openless-all/app/src-tauri/src/coordinator/dictation.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 5f1139a5..f0311a9b 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -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); From fc45d913c4c486d0688d2b67295e765f0ab59ea7 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:48:35 +0800 Subject: [PATCH 05/15] fix(build): update stale windows-startup-lifecycle contract test Three assertMatch patterns referenced the old 'checking' gate state and pollHotkeyStatus function that were removed during App.tsx refactor. The script exited with code 1 silently since it was not wired to any CI step. Updated patterns to match the current inline while-loop implementation: - checks for os === 'win' branch - checks for status.state !== 'starting' -> setGate('ready') sequence Also registered the script in package.json as check:windows-startup-lifecycle. --- openless-all/app/package.json | 3 ++- .../windows-startup-lifecycle-contract.test.mjs | 14 ++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/openless-all/app/package.json b/openless-all/app/package.json index fa4af44a..44464e1a 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -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", diff --git a/openless-all/app/scripts/windows-startup-lifecycle-contract.test.mjs b/openless-all/app/scripts/windows-startup-lifecycle-contract.test.mjs index e4080749..581490ca 100644 --- a/openless-all/app/scripts/windows-startup-lifecycle-contract.test.mjs +++ b/openless-all/app/scripts/windows-startup-lifecycle-contract.test.mjs @@ -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\(isTauri \? 'checking' : 'ready'\);/, - 'desktop app should start in checking gate before claiming ready', -); -assertMatch( - appTsx, - /if \(os === 'win' && gate === 'checking'\) return;/, - 'windows should not show the main shell while startup gate is still checking', + /if \(os === 'win'\)/, + 'windows startup gate should branch on os === win', ); 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', ); From b28dcc3adb4972f01bc9bc1a99ae7c0e894a8272 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:50:27 +0800 Subject: [PATCH 06/15] fix(security): restrict coding_agent exe to allowlist and main window only coding_agent_detect and coding_agent_run_test previously accepted any non- empty string as exe and were callable from any Tauri window. This allowed any compromised webview to spawn arbitrary local binaries. - validate_exe: allows bare 'claude' or absolute paths under known install dirs (~/.local/bin, /usr/local/bin, /opt/homebrew/bin, /usr/bin) - ensure_main_window: both commands now require label == 'main' - Window param added to both command signatures (Tauri injects it, no frontend change needed) --- .../src-tauri/src/coding_agent/commands.rs | 71 ++++++++++++++++--- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src-tauri/src/coding_agent/commands.rs b/openless-all/app/src-tauri/src/coding_agent/commands.rs index 7216651f..d62abc0b 100644 --- a/openless-all/app/src-tauri/src/coding_agent/commands.rs +++ b/openless-all/app/src-tauri/src/coding_agent/commands.rs @@ -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 { - 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) -> Result { + 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) -> ClaudeDetectionWire { - let exe = normalize_exe(exe); +pub async fn coding_agent_detect( + window: Window, + exe: Option, +) -> Result { + 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) -> 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) -> ClaudeDetectionWire { /// 若 workdir 是 git 仓库,运行前做一次 `git stash create` 快照(可回滚)。 #[tauri::command] pub async fn coding_agent_run_test( + window: Window, app: AppHandle, prompt: String, exe: Option, @@ -85,11 +137,12 @@ pub async fn coding_agent_run_test( model: Option, max_budget_usd: Option, ) -> 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 From 94497f2cca9430e1521336d6986bd1658fec78c7 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:51:06 +0800 Subject: [PATCH 07/15] fix(security): add main-window guard to less_computer_approve Any window with IPC access could call less_computer_approve with a token received from the less-computer event stream and force approval of a high-risk agent command. Guard restricts calls to the main window. The less-computer window should relay approve/deny via a Tauri event to the main window rather than calling this IPC directly. --- openless-all/app/src-tauri/src/commands/qa.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index 33f92254..3d2acef9 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -77,7 +77,19 @@ pub fn less_computer_window_resize(coord: CoordinatorState<'_>, height: f64) { } /// 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。 +/// +/// 安全:仅允许 main 窗口调用。less-computer 窗口收到 token 后应通过 Tauri 事件 +/// 将操作转发给主窗口,而非直接调用此命令。 #[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() != "main" { + return Err("approval can only be submitted from the main window".to_string()); + } coord.less_computer_approve(&token, approved); + Ok(()) } From 192ae8c9c164739fdd811835c2aa1e373f454ad9 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:51:40 +0800 Subject: [PATCH 08/15] fix(security): validate Android update URL against trusted prefixes before download app_download_and_install_android_update accepted any URL from the frontend and passed it directly to the HTTP client, enabling SSRF (e.g., requests to 169.254.169.254, localhost, or internal services). The signature check runs only after the full download, so the malicious request still completed. Validate url starts with the known GitHub direct or fastgit.cc mirror prefix before making any network request. CI-verified-only for the Android cfg path; the guard runs on all mobile builds and the macOS build stays green. --- openless-all/app/src-tauri/src/commands/settings.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 855eddea..03d96fb0 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -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; From a1bf73ca055e8775e122f4f27f49b84c63aed0ab Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:53:11 +0800 Subject: [PATCH 09/15] fix(security): add global PIN failure rate-limit to remote server Per-IP rate limit (5 fails/60s) can be bypassed by rotating source IPs on a LAN (/16 gives ~327k attempts/minute across 65535 IPs). Added a global (cross-IP) failure counter: 20 failures within 60s triggers a Locked response for all callers until the window resets. A successful authentication resets the global counter so legitimate users are not permanently penalised after a brute-force attempt. --- .../app/src-tauri/src/remote_server/mod.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index 347cc263..83d33cdf 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -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; @@ -283,6 +287,8 @@ struct WsState { app: AppHandle, /// 按源 IP 的 PIN 失败计数 + 锁定截止时刻(防爆破;TLS+6 位 PIN 已是主防线)。 pin_fails: Mutex)>>, + /// 全局 PIN 失败计数 + 计数窗口起始时刻(防跨 IP 分布式暴力)。 + pin_global_fails: Mutex<(u32, Instant)>, /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。 cert_der: Vec, /// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。 @@ -446,6 +452,7 @@ pub async fn start(cfg: RemoteServerConfig) -> Result, 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 +772,9 @@ fn verify_hello(txt: &str, state: &Arc, 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 撑爆表。 From 9382e7022c55aa059558757c57a31aa2a530fe44 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:53:37 +0800 Subject: [PATCH 10/15] fix(security): prioritize direct GitHub endpoint over fastgit.cc mirror fastgit.cc is a third-party proxy not controlled by the project. With it as the primary endpoint a compromised mirror could serve a tampered manifest (e.g. pointing to an older version for a downgrade attack). Move the direct GitHub URL to first position; fastgit.cc remains as a fallback for users in regions with poor GitHub connectivity. --- openless-all/app/src-tauri/tauri.conf.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 81b7e17c..24d680e8 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -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" ] } } From d4776325d367cbb64a95f55c11318fe71b304968 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:54:53 +0800 Subject: [PATCH 11/15] fix(security): sanitize window title before embedding in LLM system prompt GetWindowTextW (Windows) returns the raw window title string, which is fully attacker-controlled. Embedding it unsanitized in the system prompt allows any foreground application to inject LLM instructions (prompt injection). Sanitize the front_app string before use in context_premise: - strip newlines and CR (prevent multi-line instruction injection) - strip Markdown/XML delimiters # < > (prevent structural injection) - truncate to 100 chars (no legitimate app name is longer) --- .../app/src-tauri/src/polish/prompt_compose.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/polish/prompt_compose.rs b/openless-all/app/src-tauri/src/polish/prompt_compose.rs index 78c96e1a..b0fd8a9c 100644 --- a/openless-all/app/src-tauri/src/polish/prompt_compose.rs +++ b/openless-all/app/src-tauri/src/polish/prompt_compose.rs @@ -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( From 9c55e01910156fd81b01db5aa5e3164568451ece Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:55:25 +0800 Subject: [PATCH 12/15] fix(security): use rejection sampling in generate_pin to eliminate modulo bias u32::MAX (4294967295) is not a multiple of 1,000,000, so a simple modulo gives ~295 low-valued PINs a ~0.03% higher probability. For a security PIN the correct approach is to discard values in the biased tail region and resample. The rejection probability is ~0.007% so the loop terminates in one iteration with overwhelming probability. --- .../app/src-tauri/src/remote_server/mod.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index 83d33cdf..21532a7e 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -108,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 { From 3024b302d1fa729f167069d3056b44245743a9b3 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:55:45 +0800 Subject: [PATCH 13/15] fix(android): use startService for REPLACE_OVERLAY and REFRESH_LAYOUT actions On Android 12+ (API 31+), startForegroundService called from a backgrounded app throws ForegroundServiceStartNotAllowedException. REPLACE_OVERLAY and REFRESH_LAYOUT are sent to an already-running overlay service (same as SHOW/HIDE), so they must use startService, not startForegroundService. Extend the startService guard to include both action suffixes. CI-verified-only (Android cfg; macOS build stays green). --- openless-all/app/src-tauri/src/android/jni.rs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 72e9e1e4..342d98fc 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -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, From cf67fabf91b66fa17980559778528943ff21c796 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 10:56:12 +0800 Subject: [PATCH 14/15] fix(android): add 200 MB size guard to APK download buffer The entire APK was accumulated in memory with no size cap. A compromised manifest could point to an unlimited stream, exhausting the device heap. Check content-length upfront and reject if > 200 MB; also enforce the cap during streaming so chunked responses without Content-Length are also guarded. 200 MB is well above any real APK size (~50 MB current). CI-verified-only (Android cfg; macOS build stays green). --- openless-all/app/src-tauri/src/android/updater.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index f57c0a99..ae176adb 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -219,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(); @@ -226,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", From 51ec4cb5bdc79d463de10df6051ebef1b5ff3676 Mon Sep 17 00:00:00 2001 From: appergb Date: Wed, 17 Jun 2026 11:01:56 +0800 Subject: [PATCH 15/15] fix(security): scope less_computer_approve to the Less Computer window, not main The approval UI (LessComputerPanel) renders in the 'less-computer' window and calls less_computer_approve directly, so a main-only guard would reject every approval and break the feature. Allow the 'less-computer' window (the single legitimate approval surface) and block main/capsule/qa/glow. --- openless-all/app/src-tauri/Cargo.lock | 91 +++++++++++++++++++ openless-all/app/src-tauri/src/commands/qa.rs | 8 +- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 6525d93e..4a4b8347 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -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" diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index 3d2acef9..acc64238 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -78,8 +78,8 @@ pub fn less_computer_window_resize(coord: CoordinatorState<'_>, height: f64) { /// 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。 /// -/// 安全:仅允许 main 窗口调用。less-computer 窗口收到 token 后应通过 Tauri 事件 -/// 将操作转发给主窗口,而非直接调用此命令。 +/// 安全:审批 UI 渲染在 less-computer 窗口(LessComputerPanel),故仅允许该窗口提交, +/// 拦截 main / capsule / qa / glow 等其它窗口伪造审批 —— 把可调用窗口从 5 个收紧到 1 个。 #[tauri::command] pub fn less_computer_approve( window: Window, @@ -87,8 +87,8 @@ pub fn less_computer_approve( token: String, approved: bool, ) -> Result<(), String> { - if window.label() != "main" { - return Err("approval can only be submitted from the main window".to_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(())