加固浏览器清扫与收束:杀前镜像复核、收割结果确认、镜像默认视口

sweep.rs:杀前新增活进程镜像路径复核(Windows QueryFullProcessImageNameW / Linux /proc/pid/exe),打开失败 fail-closed 拒绝
sweep.rs:Unix 新增属主目录 euid 检查,拒绝他人在 /tmp 伪造的 ga-browser-* 目录
sweep.rs:Windows 杀树改为追踪制,全部成员从快照消失才报成功,root 先死子进程残留不再误报
sweep.rs:Unix 检查 kill 返回值并以 kill(pid,0) 确认进程消失后才报成功
process.rs:reap 返回 root 退出确认,非 Windows confirm_reaped 用 try_wait 兜底,挂起/杀失败暴露为 browser-cleanup-unconfirmed
process.rs:handler_config 镜像 BrowserConfigBuilder 默认 viewport(Some 800x600),与旧 Browser::launch 行为一致
测试:杀树两个用例断言整树成员真实消失,新增镜像复核测试
This commit is contained in:
2026-09-22 17:06:09 +08:00
parent bce465429d
commit 2b24da2557
3 changed files with 263 additions and 42 deletions
@@ -6,6 +6,7 @@ use std::time::Instant;
use chromiumoxide::async_process::{Child as BrowserChild, ChildStderr as BrowserChildStderr};
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::handler::viewport::Viewport;
use chromiumoxide::handler::HandlerConfig;
use futures::{AsyncBufReadExt, FutureExt, StreamExt};
use tempfile::{Builder as TempDirBuilder, TempDir};
@@ -76,14 +77,17 @@ const MAX_LAUNCH_STDERR_BYTES: usize = 64 * 1024;
const MAX_WS_URL_LEN: usize = 512;
/// 必须与 browser_config 的 builder 调用保持一致:request_timeout /
/// enable_request_intercept / disable_cache 在这里逐项镜像;其余字段
/// ignore_https_errors / ignore_invalid_messages / viewport)与
/// BrowserConfigBuilder 默认值相同,沿用 HandlerConfig::default()。
/// enable_request_intercept / disable_cache 在这里逐项镜像;viewport 镜像
/// BrowserConfigBuilder 的默认值 Some(Viewport::default())800x600,旧
/// Browser::launch 会把它带入 HandlerConfig);其余字段
/// ignore_https_errors / ignore_invalid_messages)沿用
/// HandlerConfig::default(),与 builder 默认值相同。
fn handler_config() -> HandlerConfig {
let mut config = HandlerConfig::default();
config.request_timeout = BROWSER_TIMEOUT;
config.request_intercept = true;
config.cache_enabled = false;
config.viewport = Some(Viewport::default());
config
}
@@ -204,33 +208,48 @@ struct BrowserProcessGuard {
impl BrowserProcessGuard {
/// 整树收割:Job 终止全树,再 kill+wait root 兜底(Unix 与 Job
/// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出)。
async fn reap(&mut self) {
/// 返回是否已确认 root 进程退出。
async fn reap(&mut self) -> bool {
#[cfg(windows)]
if let Some(job) = &self.job {
let _ = job.terminate();
}
let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await;
let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await;
matches!(
tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await,
Ok(Ok(_))
)
}
async fn confirm_reaped(&self) -> Result<(), String> {
async fn confirm_reaped(&mut self) -> Result<(), String> {
#[cfg(windows)]
if let Some(job) = &self.job {
let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT;
loop {
if job
.is_empty()
.map_err(|_| "browser-tree-reap-unconfirmed".to_string())?
{
return Ok(());
{
if let Some(job) = &self.job {
let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT;
loop {
if job
.is_empty()
.map_err(|_| "browser-tree-reap-unconfirmed".to_string())?
{
return Ok(());
}
if Instant::now() >= deadline {
return Err("browser-tree-reap-unconfirmed".into());
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
if Instant::now() >= deadline {
return Err("browser-tree-reap-unconfirmed".into());
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
Ok(())
}
#[cfg(not(windows))]
{
// 兜底确认 root 确已退出:否则调用方会在浏览器仍存活时删除
// 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。
match self.child.inner.try_wait() {
Ok(Some(_)) => Ok(()),
_ => Err("browser-tree-reap-unconfirmed".into()),
}
}
Ok(())
}
}
@@ -262,7 +281,9 @@ impl OwnedBrowser {
if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) {
return Ok(());
}
self.process.reap().await;
if !self.process.reap().await {
return Err("browser-cleanup-unconfirmed".into());
}
self.process.confirm_reaped().await
}
}
@@ -305,7 +326,7 @@ async fn launch_owned_browser(
{
Ok(pair) => pair,
Err(error) => {
process.reap().await;
let _ = process.reap().await;
return Err(error);
}
};
@@ -319,12 +340,12 @@ async fn launch_owned_browser(
Ok(Ok(pair)) => pair,
Ok(Err(_)) => {
drain_task.abort();
process.reap().await;
let _ = process.reap().await;
return Err(OwnedBrowserLaunchError::ConnectFailed);
}
Err(_) => {
drain_task.abort();
process.reap().await;
let _ = process.reap().await;
return Err(OwnedBrowserLaunchError::ConnectTimeout);
}
};
@@ -536,10 +557,11 @@ mod health_tests {
assert_eq!(config.request_timeout, BROWSER_TIMEOUT);
assert!(config.request_intercept);
assert!(!config.cache_enabled);
// 其余字段必须与 BrowserConfigBuilder 默认值保持一致
// 其余字段必须与 BrowserConfigBuilder 默认值保持一致
// builder 默认 viewport 为 Some(Viewport::default()),即 800x600)。
assert!(config.ignore_https_errors);
assert!(config.ignore_invalid_messages);
assert!(config.viewport.is_none());
assert_eq!(config.viewport, Some(Viewport::default()));
}
#[tokio::test]
@@ -85,6 +85,23 @@ fn process_identity_matches(pid: u32, identity: &str) -> bool {
.is_some_and(|live| live == identity)
}
/// 可执行文件路径等价性:双方规范化后比较,Windows 下忽略大小写。
fn same_executable_path(actual: &Path, expected: &Path) -> bool {
let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let actual = canonical(actual);
let expected = canonical(expected);
#[cfg(windows)]
{
return actual
.to_string_lossy()
.eq_ignore_ascii_case(&expected.to_string_lossy());
}
#[cfg(not(windows))]
{
actual == expected
}
}
/// exe 必须仍是系统标准路径下的可信浏览器,防止伪造 owner.json 借刀杀进程。
fn trusted_browser_executable(path: &Path) -> bool {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
@@ -93,22 +110,88 @@ fn trusted_browser_executable(path: &Path) -> bool {
}
system_browser_candidates()
.into_iter()
.any(|(candidate, _)| candidate.canonicalize().unwrap_or(candidate) == canonical)
.any(|(candidate, _)| same_executable_path(&candidate, path))
}
/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行
/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造
/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。
#[cfg(windows)]
fn live_process_executable_matches(pid: u32, expected: &Path) -> bool {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
};
// SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return false;
}
let mut buffer = [0u16; 1024];
let mut length = buffer.len() as u32;
// SAFETY: buffer 可写,length 先传入容量、返回实际长度。
let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) };
// SAFETY: handle 是本函数持有的合法句柄。
unsafe { CloseHandle(handle) };
if okay == 0 || length == 0 {
return false;
}
let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize]));
same_executable_path(&actual, expected)
}
#[cfg(target_os = "linux")]
fn live_process_executable_matches(pid: u32, expected: &Path) -> bool {
let Ok(actual) = fs::read_link(format!("/proc/{pid}/exe")) else {
return false;
};
same_executable_path(&actual, expected)
}
// macOS 等无 /proc 的平台没有廉价的镜像核对手段,由目录属主检查兜底。
#[cfg(all(unix, not(target_os = "linux")))]
fn live_process_executable_matches(_pid: u32, expected: &Path) -> bool {
expected.is_file()
}
/// /tmp 是全局可写目录:属主目录必须由本用户创建,否则视为伪造并拒绝处置。
#[cfg(unix)]
fn directory_owned_by_current_user(dir: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
dir.metadata()
// SAFETY: geteuid 无前置条件,总是安全的。
.map(|metadata| metadata.uid() == unsafe { libc::geteuid() })
.unwrap_or(false)
}
fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> {
#[cfg(windows)]
{
// 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判
// 成功——root 先死而子进程残留时不再误报收割完成。
let mut tracked: Vec<u32> = Vec::new();
for _ in 0..TREE_KILL_MAX_PASSES {
// 每轮先复核 root 身份:进程已退出或 PID 被复用时立即停止。
if !process_identity_matches(root_pid, expected_identity) {
let snapshot = windows_process_snapshot()?;
let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid);
if root_present && process_identity_matches(root_pid, expected_identity) {
// root 仍是目标浏览器:发现并追踪当前整棵子树。
for pid in windows_process_tree_pids_from(&snapshot, root_pid)? {
if !tracked.contains(&pid) {
tracked.push(pid);
}
}
} else {
// root 已退出或 PID 被复用:复用者与本任务无关,不再追踪 root。
tracked.retain(|pid| *pid != root_pid);
}
// 只统计仍存活的成员;全部消失才算收割完成。
tracked.retain(|pid| snapshot.iter().any(|(live, _)| live == pid));
if tracked.is_empty() {
return Ok(());
}
let pids = windows_process_tree_pids(root_pid)?;
if pids.is_empty() {
return Ok(());
}
for pid in &pids {
for pid in &tracked {
// 终止结果以“下一轮快照中是否消失”验证;对正在退出的进程
// OpenProcess 的瞬时失败会在下一轮自然消解。
windows_terminate_process(*pid);
}
std::thread::sleep(TREE_KILL_PASS_INTERVAL);
@@ -118,15 +201,27 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(
#[cfg(not(windows))]
{
// 只杀 root:子进程随 IPC 断开级联退出(与运行期收割语义一致)。
if process_identity_matches(root_pid, expected_identity) {
unsafe { libc::kill(root_pid as i32, libc::SIGKILL) };
if !process_identity_matches(root_pid, expected_identity) {
return Ok(());
}
Ok(())
// SAFETY: 目标 PID 与启动身份均已复核。
if unsafe { libc::kill(root_pid as i32, libc::SIGKILL) } != 0 {
return Err("browser-sweep-kill-failed".into());
}
// SIGKILL 后必须确认进程真正消失再报成功。
for _ in 0..TREE_KILL_MAX_PASSES {
// SAFETY: signal 0 仅做存活探测,无副作用。
if unsafe { libc::kill(root_pid as i32, 0) } != 0 {
return Ok(());
}
std::thread::sleep(TREE_KILL_PASS_INTERVAL);
}
Err("browser-sweep-tree-not-reaped".into())
}
}
#[cfg(windows)]
fn windows_process_tree_pids(root_pid: u32) -> Result<Vec<u32>, String> {
fn windows_process_snapshot() -> Result<Vec<(u32, u32)>, String> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
@@ -149,6 +244,14 @@ fn windows_process_tree_pids(root_pid: u32) -> Result<Vec<u32>, String> {
}
// SAFETY: snapshot 是本函数持有的合法句柄。
unsafe { CloseHandle(snapshot) };
Ok(processes)
}
#[cfg(windows)]
fn windows_process_tree_pids_from(
processes: &[(u32, u32)],
root_pid: u32,
) -> Result<Vec<u32>, String> {
if !processes.iter().any(|(pid, _)| *pid == root_pid) {
return Ok(Vec::new());
}
@@ -156,7 +259,7 @@ fn windows_process_tree_pids(root_pid: u32) -> Result<Vec<u32>, String> {
let mut index = 0;
while index < tree.len() {
let parent = tree[index];
for (pid, ppid) in &processes {
for (pid, ppid) in processes {
if *ppid == parent && !tree.contains(pid) {
tree.push(*pid);
}
@@ -233,6 +336,12 @@ fn sweep_owned_directory(
owner: &BrowserProcessOwner,
notes: &mut Vec<String>,
) {
// /tmp 全局可写:属主目录必须是本用户创建,否则视为伪造,拒绝处置。
#[cfg(unix)]
if !directory_owned_by_current_user(dir) {
notes.push(format!("kept-foreign-dir:{name}"));
return;
}
// 另一个活着的 App 实例仍持有这轮浏览器:整目录跳过。
if process_identity_matches(owner.owner_pid, &owner.owner_start_identity) {
return;
@@ -248,6 +357,12 @@ fn sweep_owned_directory(
notes.push(format!("kept-untrusted-executable:{name}"));
return;
}
// 活进程镜像必须与声明的浏览器一致:防止伪造 owner.json 借清扫
// 终止本用户的无关进程。
if !live_process_executable_matches(owner.browser_pid, Path::new(&owner.executable)) {
notes.push(format!("kept-image-mismatch:{name}"));
return;
}
match kill_browser_process_tree(owner.browser_pid, &owner.browser_start_identity) {
Ok(()) => {
let _ = fs::remove_dir_all(dir);
@@ -405,13 +520,32 @@ mod tests {
let identity = crate::runner::external_agent_runner_process_start_identity(pid)
.expect("fixture identity")
.expect("fixture identity present");
let before = windows_process_tree_pids(pid).expect("tree snapshot");
let before =
windows_process_tree_pids_from(&windows_process_snapshot().expect("snapshot"), pid)
.expect("tree snapshot");
assert!(before.len() >= 2, "fixture 必须包含子进程: {before:?}");
kill_browser_process_tree(pid, &identity).expect("kill tree");
assert!(windows_process_tree_pids(pid)
.map(|tree| tree.is_empty())
.unwrap_or(true));
// 不仅 root 消失,快照里记录到的每个树成员(含 ping 子进程)都必须
// 真正被收割,不能只凭 root 不在就判成功。
let alive = windows_process_snapshot().expect("snapshot after kill");
for member in &before {
assert!(
!alive.iter().any(|(live, _)| live == member),
"fixture 进程 {member} 必须被收割: {before:?}"
);
}
let _ = child.kill();
let _ = child.wait();
}
#[cfg(any(windows, target_os = "linux"))]
#[test]
fn live_process_executable_matches_current_process_image() {
let exe = std::env::current_exe().unwrap();
assert!(live_process_executable_matches(std::process::id(), &exe));
assert!(!live_process_executable_matches(
std::process::id(),
Path::new("C:\\fixture\\chrome.exe")
));
}
}
@@ -1816,6 +1816,33 @@ setInterval(() => {}, 1000);
fn windows_process_job_terminate_reaps_process_tree() {
use std::process::{Command, Stdio};
// 系统进程快照(pid, ppid),用于证明 ping 子进程真实存在并被收割。
fn windows_process_snapshot_for_test() -> Vec<(u32, u32)> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
};
// SAFETY: 快照句柄非 INVALID_HANDLE_VALUE 时由 CloseHandle 释放。
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return Vec::new();
}
let mut entry = PROCESSENTRY32W::default();
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
let mut processes = Vec::new();
// SAFETY: entry 指向可写的 PROCESSENTRY32WdwSize 已初始化。
let mut available = unsafe { Process32FirstW(snapshot, &mut entry) };
while available != 0 {
processes.push((entry.th32ProcessID, entry.th32ParentProcessID));
// SAFETY: 同上。
available = unsafe { Process32NextW(snapshot, &mut entry) };
}
// SAFETY: snapshot 是本函数持有的合法句柄。
unsafe { CloseHandle(snapshot) };
processes
}
// cmd 启动第一个 ping 子进程后整树存活;terminate 必须连子进程一起收割。
// timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null stdio 下存活。
let mut child = Command::new("cmd.exe")
@@ -1838,6 +1865,26 @@ fn windows_process_job_terminate_reaps_process_tree() {
.expect("spawn cmd fixture");
let job = WindowsProcessJob::assign_std(&child).expect("assign job");
thread::sleep(Duration::from_millis(500));
// 前置检查不能只凭 Job 非空:快照里必须真的看到 cmd 拉起了 ping 子进程。
let tree_before: Vec<u32> = {
let snapshot = windows_process_snapshot_for_test();
let mut tree = vec![child.id()];
let mut index = 0;
while index < tree.len() {
let parent = tree[index];
for (pid, ppid) in &snapshot {
if *ppid == parent && !tree.contains(pid) {
tree.push(*pid);
}
}
index += 1;
}
tree
};
assert!(
tree_before.len() >= 2,
"fixture 必须包含 ping 子进程,否则收割断言是空转: {tree_before:?}"
);
assert!(
!job.is_empty().expect("query job"),
"fixture 进程树必须先存活,否则收割断言是空转"
@@ -1851,6 +1898,24 @@ fn windows_process_job_terminate_reaps_process_tree() {
);
thread::sleep(Duration::from_millis(50));
}
// Job 为空之外,快照里的整棵树(含 ping 子进程)也必须真实消失。
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let snapshot = windows_process_snapshot_for_test();
let survivors: Vec<u32> = tree_before
.iter()
.copied()
.filter(|pid| snapshot.iter().any(|(live, _)| live == pid))
.collect();
if survivors.is_empty() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Job terminate 后 fixture 子进程仍存活: {survivors:?}"
);
thread::sleep(Duration::from_millis(50));
}
let _ = child.kill();
let _ = child.wait();
}