fix(security): 收紧路径与 Gateway 清理策略

This commit is contained in:
晴天
2026-06-15 16:20:35 +08:00
parent d585402b69
commit 0a65ea7c7e
13 changed files with 424 additions and 60 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -366,7 +366,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.18.4"
version = "0.18.5"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
version = "0.18.4"
version = "0.18.5"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]

View File

@@ -1278,12 +1278,13 @@ fn select_calibration_source(current: Option<Value>, backup: Option<Value>) -> (
match (current, backup) {
(Some(current), Some(backup)) => {
let current_score = calibration_richness_score(&current);
let backup_score = calibration_richness_score(&backup);
if backup_score > current_score {
("backup".into(), backup)
} else {
("current".into(), current)
if current_score == 0 {
let backup_score = calibration_richness_score(&backup);
if backup_score > 0 {
return ("backup".into(), backup);
}
}
("current".into(), current)
}
(Some(current), None) => ("current".into(), current),
(None, Some(backup)) => ("backup".into(), backup),
@@ -7601,11 +7602,13 @@ pub fn invalidate_path_cache() -> Result<(), String> {
#[cfg(test)]
mod write_openclaw_config_merge_tests {
use super::apply_reset_inheritance;
use super::calibration_richness_score;
use super::merge_configs_preserving_fields;
use super::node_version_satisfies_requirement;
use super::openclaw_version_requires_node_22_19;
#[cfg(target_os = "windows")]
use super::resolve_openclaw_cli_input_path;
use super::select_calibration_source;
use super::standalone_bundled_node_bin;
use serde_json::json;
use std::path::PathBuf;
@@ -7709,6 +7712,54 @@ mod write_openclaw_config_merge_tests {
assert!(origins.iter().any(|v| v == "tauri://localhost"));
}
#[test]
fn select_calibration_source_prefers_current_over_richer_backup() {
let current = json!({
"models": { "providers": {} },
"gateway": {
"auth": { "mode": "token", "token": "secret-current" },
}
});
let backup = json!({
"models": {
"providers": {
"old": { "type": "openai", "apiKey": "old" }
}
},
"agents": {
"defaults": { "workspace": "/tmp/work" },
"list": [{ "id": "old-agent" }]
},
"channels": { "telegram": { "enabled": true } },
"gateway": {
"auth": { "mode": "token", "token": "secret-backup" },
"controlUi": { "allowedOrigins": ["http://localhost:3000"] }
}
});
assert!(calibration_richness_score(&backup) > calibration_richness_score(&current));
let (source, seed) = select_calibration_source(Some(current.clone()), Some(backup));
assert_eq!(source, "current");
assert_eq!(seed, current);
}
#[test]
fn select_calibration_source_uses_backup_when_current_empty() {
let backup = json!({
"models": {
"providers": {
"old": { "type": "openai", "apiKey": "old" }
}
}
});
let (source, seed) = select_calibration_source(Some(json!({})), Some(backup.clone()));
assert_eq!(source, "backup");
assert_eq!(seed, backup);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_cli_input_rejects_extensionless_openclaw_shim() {

View File

@@ -17261,33 +17261,56 @@ const FS_MAX_LIST_ENTRIES: usize = 2000; // 单次最多返回 2000 条
/// 返回安全的绝对路径,或 Err。
fn validate_hermes_fs_path(rel_path: &str) -> Result<PathBuf, String> {
let root = hermes_home();
// 空 = 根目录
let target = if rel_path.is_empty() {
root.clone()
validate_hermes_fs_path_under(&root, rel_path)
}
fn validate_hermes_fs_path_under(
root: &std::path::Path,
rel_path: &str,
) -> Result<PathBuf, String> {
let canonical_root = root
.canonicalize()
.map_err(|e| format!("Hermes 目录不存在: {e}"))?;
if rel_path.trim().is_empty() {
return Ok(canonical_root);
}
let p = std::path::Path::new(rel_path);
if p.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err("路径不能包含 ..".into());
}
let target = if p.is_absolute() {
p.to_path_buf()
} else {
// 拒绝绝对路径输入(必须相对于 hermes_home
let p = std::path::Path::new(rel_path);
if p.is_absolute() {
// 允许绝对路径,但必须以 root 开头(用 starts_with 检查)
let canonical_root = root.canonicalize().unwrap_or(root.clone());
let canonical_target = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
if !canonical_target.starts_with(&canonical_root) {
return Err(format!("路径必须在 {} 子树内", root.to_string_lossy()));
}
canonical_target
} else {
// 相对路径:拼到 root 下,再 canonicalize 防 ..
let joined = root.join(p);
// 父目录必须存在才能 canonicalize对不存在的新文件 fallback 到 joined
let canon = joined.canonicalize().unwrap_or(joined.clone());
let canonical_root = root.canonicalize().unwrap_or(root.clone());
if !canon.starts_with(&canonical_root) {
return Err(format!("路径不能跳出 {} 目录", root.to_string_lossy()));
}
canon
}
canonical_root.join(p)
};
Ok(target)
let canonical_target = if target.exists() {
target
.canonicalize()
.map_err(|e| format!("解析路径失败: {e}"))?
} else {
let parent = target
.parent()
.ok_or_else(|| "路径缺少父目录".to_string())?;
let canonical_parent = parent
.canonicalize()
.map_err(|e| format!("父目录不存在或不可访问: {e}"))?;
let Some(name) = target.file_name() else {
return Err("路径缺少文件名".into());
};
canonical_parent.join(name)
};
if !canonical_target.starts_with(&canonical_root) {
return Err(format!(
"路径不能跳出 {} 目录",
canonical_root.to_string_lossy()
));
}
Ok(canonical_target)
}
#[tauri::command]
@@ -17439,6 +17462,44 @@ pub async fn hermes_fs_write(path: String, content: String) -> Result<Value, Str
}))
}
#[cfg(test)]
mod hermes_fs_path_tests {
use super::validate_hermes_fs_path_under;
fn unique_temp_dir(prefix: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"{prefix}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn rejects_parent_dir_segments() {
let root = unique_temp_dir("hermes-fs-path");
std::fs::create_dir_all(&root).unwrap();
let err = validate_hermes_fs_path_under(&root, "../outside.txt").unwrap_err();
let _ = std::fs::remove_dir_all(&root);
assert!(err.contains(".."));
}
#[test]
fn allows_new_file_under_root() {
let root = unique_temp_dir("hermes-fs-path-new");
std::fs::create_dir_all(root.join("notes")).unwrap();
let target = validate_hermes_fs_path_under(&root, "notes/new.md").unwrap();
let _ = std::fs::remove_dir_all(&root);
assert!(target.ends_with(std::path::Path::new("notes").join("new.md")));
}
}
// ============================================================================
// Unit tests for the pure YAML helpers (no filesystem I/O).
// ============================================================================

View File

@@ -112,6 +112,14 @@ fn operator_token_is_usable(value: Option<&serde_json::Value>) -> bool {
.all(|scope| scopes.iter().any(|existing| existing == scope))
}
fn operator_token_is_revoked(value: Option<&serde_json::Value>) -> bool {
value
.and_then(|v| v.as_object())
.and_then(|obj| obj.get("revokedAtMs"))
.map(|v| !v.is_null())
.unwrap_or(false)
}
fn ensure_operator_token(
obj: &mut serde_json::Map<String, serde_json::Value>,
now_ms: u64,
@@ -129,12 +137,16 @@ fn ensure_operator_token(
}
let existing = tokens_obj.get(CONTROL_UI_ROLE).and_then(|v| v.as_object());
let token = existing
.and_then(|entry| entry.get("token"))
.and_then(|v| v.as_str())
.filter(|token| !token.trim().is_empty())
.map(|token| token.to_string())
.unwrap_or_else(generate_pairing_token);
let token = if operator_token_is_revoked(tokens_obj.get(CONTROL_UI_ROLE)) {
generate_pairing_token()
} else {
existing
.and_then(|entry| entry.get("token"))
.and_then(|v| v.as_str())
.filter(|token| !token.trim().is_empty())
.map(|token| token.to_string())
.unwrap_or_else(generate_pairing_token)
};
let created_at_ms = existing
.and_then(|entry| entry.get("createdAtMs"))
.and_then(|v| v.as_u64())
@@ -511,4 +523,44 @@ mod tests {
assert!(!changed);
assert_eq!(entry["approvedAtMs"], serde_json::json!(1));
}
#[test]
fn normalize_existing_pairing_rotates_revoked_operator_token() {
let mut entry = serde_json::json!({
"deviceId": "device-1",
"publicKey": "key",
"platform": "windows",
"deviceFamily": "desktop",
"clientId": "openclaw-control-ui",
"clientMode": "ui",
"role": "operator",
"roles": ["operator"],
"scopes": scope_values(),
"approvedScopes": scope_values(),
"tokens": {
"operator": {
"token": "revoked-token",
"role": "operator",
"scopes": scope_values(),
"createdAtMs": 1,
"revokedAtMs": 999
}
},
"createdAtMs": 1,
"approvedAtMs": 1
});
let changed = normalize_control_ui_pairing(&mut entry, "device-1", "key", "windows", 1234);
assert!(changed);
let token = entry["tokens"]["operator"]["token"]
.as_str()
.expect("token");
assert_ne!(token, "revoked-token");
assert!(entry["tokens"]["operator"].get("revokedAtMs").is_none());
assert_eq!(
entry["tokens"]["operator"]["rotatedAtMs"],
serde_json::json!(1234)
);
}
}

View File

@@ -2000,6 +2000,101 @@ mod platform {
None
}
fn find_listening_pids(port: u16) -> Vec<u32> {
let mut pids = Vec::new();
let filter = format!("sport = :{port}");
if let Ok(output) = std::process::Command::new("ss")
.args(["-ltnp", &filter])
.output()
{
let text = String::from_utf8_lossy(&output.stdout);
for segment in text.split("pid=").skip(1) {
let pid_text: String = segment.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(pid) = pid_text.parse::<u32>() {
if pid > 0 && !pids.contains(&pid) {
pids.push(pid);
}
}
}
}
if pids.is_empty() {
if let Ok(output) = std::process::Command::new("lsof")
.args(["-i", &format!("TCP:{port}"), "-sTCP:LISTEN", "-t"])
.output()
{
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if let Ok(pid) = line.trim().parse::<u32>() {
if pid > 0 && !pids.contains(&pid) {
pids.push(pid);
}
}
}
}
}
pids
}
fn read_process_command_line(pid: u32) -> Option<String> {
let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
let text = raw
.split(|b| *b == 0)
.filter(|part| !part.is_empty())
.map(|part| String::from_utf8_lossy(part).to_string())
.collect::<Vec<_>>()
.join(" ");
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
fn is_gateway_command_line(cmdline: &str) -> bool {
let lower = cmdline.to_ascii_lowercase();
lower.contains("openclaw") && lower.contains("gateway")
}
fn is_gateway_port_responsive(port: u16) -> bool {
use std::io::{Read, Write};
let addr = format!("127.0.0.1:{port}");
let Ok(socket_addr) = addr.parse() else {
return false;
};
let Ok(mut stream) =
std::net::TcpStream::connect_timeout(&socket_addr, Duration::from_secs(3))
else {
return false;
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(3)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
let req = format!("GET /health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\n\r\n");
if stream.write_all(req.as_bytes()).is_err() {
return false;
}
let mut buf = [0u8; 256];
match stream.read(&mut buf) {
Ok(n) if n > 0 => {
let resp = String::from_utf8_lossy(&buf[..n]);
resp.contains("200") || resp.contains("OK")
}
_ => false,
}
}
fn is_gateway_port_responsive_with_retry(port: u16, retries: u32, interval: Duration) -> bool {
for attempt in 0..retries {
if attempt > 0 {
std::thread::sleep(interval);
}
if is_gateway_port_responsive(port) {
return true;
}
}
false
}
/// 跨平台统一检测TCP 连端口
#[allow(dead_code)]
pub async fn check_service_status(_uid: u32, _label: &str) -> (bool, Option<u32>) {
@@ -2023,23 +2118,45 @@ mod platform {
}
}
/// 清理残留的 Gateway 进程Linux 版:通过 fuser 查端口占用进程并 kill
/// 清理残留的 Gateway 进程
///
/// 只处理命令行确认是 openclaw gateway 且 /health 连续无响应的进程,避免把同端口的
/// 其他服务或仍在启动中的健康 Gateway 误杀。
fn cleanup_zombie_gateway_processes() {
let port = crate::commands::gateway_listen_port();
// 尝试用 fuser 找到端口占用进程
if let Ok(output) = std::process::Command::new("fuser")
.args([&format!("{port}/tcp")])
.output()
{
let pids = String::from_utf8_lossy(&output.stdout);
for pid_str in pids.split_whitespace() {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
eprintln!("[cleanup_zombie] killed PID {pid} on port {port}");
}
let pids = find_listening_pids(port);
if pids.is_empty() {
return;
}
let responsive =
is_gateway_port_responsive_with_retry(port, 3, std::time::Duration::from_millis(800));
for pid in pids {
let Some(cmdline) = read_process_command_line(pid) else {
super::guardian_log(&format!(
"跳过清理端口 {port} 上的 PID {pid}:无法读取命令行"
));
continue;
};
if !is_gateway_command_line(&cmdline) {
super::guardian_log(&format!(
"跳过清理端口 {port} 上的 PID {pid}:不是 openclaw gateway"
));
continue;
}
if responsive {
super::guardian_log(&format!(
"检测到健康的 Gateway 进程 (PID {pid})/health 正常响应,跳过清理"
));
continue;
}
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
super::guardian_log(&format!(
"检测到僵尸 Gateway 进程 (PID {pid}):端口 {port} 占用但 /health 连续无响应,已强制终止"
));
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "ClawPanel",
"version": "0.18.4",
"version": "0.18.5",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",