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:
14 files changed
+279
-40
No files matched your search
@@ -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',
|
||||
);
|
||||
Generated
+91
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 撑爆表。
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user