mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-30 20:21:34 +08:00
fix(security): 收紧路径与 Gateway 清理策略
This commit is contained in:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -366,7 +366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.18.4"
|
||||
version = "0.18.5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.18.4"
|
||||
version = "0.18.5"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
|
||||
@@ -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(¤t);
|
||||
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(¤t));
|
||||
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() {
|
||||
|
||||
@@ -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).
|
||||
// ============================================================================
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 连续无响应,已强制终止"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user