security: 修复命令注入/路径遍历漏洞 + 收紧 allowedOrigins + 审计日志 + OnceLock 缓存

This commit is contained in:
晴天
2026-03-06 20:51:32 +08:00
parent 7d387a4f94
commit 80197bdc60
10 changed files with 66 additions and 14 deletions

View File

@@ -5,6 +5,20 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [0.5.6] - 2026-03-06
### 安全修复 (Security)
- **dev-api.js 命令注入漏洞** — `search_log``query` 参数直接拼入 `grep` shell 命令,可注入任意系统命令。改为纯 JS 字符串匹配实现
- **dev-api.js 路径遍历漏洞** — `read_memory_file` / `write_memory_file` / `delete_memory_file` 未校验路径,可通过 `../` 读写任意文件。新增 `isUnsafePath()` 检查(与 Rust 端 `memory.rs` 对齐)
- **Gateway allowedOrigins 过于宽松** — `patch_gateway_origins()` 设置 `["*"]` 允许任何网页连接本地 Gateway WebSocket。收紧为仅允许 Tauri origin + `localhost:1420`
### 改进 (Improvements)
- **AI 助手审计日志** — `assistant_exec` / `assistant_read_file` / `assistant_write_file` 新增操作审计日志,记录到 `~/.openclaw/logs/assistant-audit.log`
- **connect frame 版本号** — `device.rs``userAgent``client.version` 从硬编码 `1.0.0` 改为编译时读取 `Cargo.toml` 版本
- **enhanced_path() 性能优化** — 使用 `OnceLock` 缓存结果,避免每次调用都扫描文件系统
## [0.5.5] - 2026-03-06
### 修复 (Bug Fixes)

View File

@@ -34,7 +34,7 @@
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。支持仪表盘监控、多模型配置、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。",
"url": "https://claw.qt.cool/",
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
"softwareVersion": "0.5.5",
"softwareVersion": "0.5.6",
"author": {
"@type": "Organization",
"name": "晴辰云 QingchenCloud",

View File

@@ -1,6 +1,6 @@
{
"name": "clawpanel",
"version": "0.5.5",
"version": "0.5.6",
"private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module",

View File

@@ -22,6 +22,10 @@ const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const SCOPES = ['operator.admin', 'operator.approvals', 'operator.pairing', 'operator.read', 'operator.write']
function isUnsafePath(p) {
return !p || p.includes('..') || p.includes('\0') || path.isAbsolute(p)
}
function readBody(req) {
return new Promise((resolve) => {
let body = ''
@@ -584,12 +588,11 @@ const handlers = {
const file = logFiles[logName] || logFiles['gateway']
const logPath = path.join(LOGS_DIR, file)
if (!fs.existsSync(logPath)) return []
try {
const output = execSync(`grep -i "${query.replace(/"/g, '\\"')}" "${logPath}" | tail -${maxResults} 2>&1`).toString()
return output.split('\n').filter(Boolean)
} catch {
return []
}
// 纯 JS 实现,避免 shell 命令注入
const content = fs.readFileSync(logPath, 'utf8')
const queryLower = (query || '').toLowerCase()
const matched = content.split('\n').filter(line => line.toLowerCase().includes(queryLower))
return matched.slice(-maxResults)
},
// Agent 管理
@@ -619,6 +622,7 @@ const handlers = {
},
read_memory_file({ path: filePath, agent_id }) {
if (isUnsafePath(filePath)) throw new Error('非法路径')
const suffix = agent_id && agent_id !== 'main' ? `/agents/${agent_id}` : ''
const full = path.join(OPENCLAW_DIR, 'workspace' + suffix, filePath)
if (!fs.existsSync(full)) return ''
@@ -626,6 +630,7 @@ const handlers = {
},
write_memory_file({ path: filePath, content, category, agent_id }) {
if (isUnsafePath(filePath)) throw new Error('非法路径')
const suffix = agent_id && agent_id !== 'main' ? `/agents/${agent_id}` : ''
const full = path.join(OPENCLAW_DIR, 'workspace' + suffix, filePath)
const dir = path.dirname(full)
@@ -635,6 +640,7 @@ const handlers = {
},
delete_memory_file({ path: filePath, agent_id }) {
if (isUnsafePath(filePath)) throw new Error('非法路径')
const suffix = agent_id && agent_id !== 'main' ? `/agents/${agent_id}` : ''
const full = path.join(OPENCLAW_DIR, 'workspace' + suffix, filePath)
if (fs.existsSync(full)) fs.unlinkSync(full)

View File

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

View File

@@ -4,6 +4,20 @@ use base64::{engine::general_purpose, Engine as _};
/// 仅在用户主动开启工具后由 AI 调用
use std::path::PathBuf;
/// 审计日志:记录 AI 助手的敏感操作exec / read / write
fn audit_log(action: &str, detail: &str) {
let log_dir = super::openclaw_dir().join("logs");
let _ = std::fs::create_dir_all(&log_dir);
let log_path = log_dir.join("assistant-audit.log");
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let line = format!("[{ts}] [{action}] {detail}\n");
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
}
/// ClawPanel 数据目录(~/.openclaw/clawpanel/
fn data_dir() -> PathBuf {
super::openclaw_dir().join("clawpanel")
@@ -125,6 +139,8 @@ pub async fn assistant_exec(command: String, cwd: Option<String>) -> Result<Stri
.to_string()
});
audit_log("EXEC", &format!("cmd={command} cwd={work_dir}"));
let output;
#[cfg(target_os = "windows")]
@@ -184,6 +200,7 @@ pub async fn assistant_exec(command: String, cwd: Option<String>) -> Result<Stri
/// 读取文件内容
#[tauri::command]
pub async fn assistant_read_file(path: String) -> Result<String, String> {
audit_log("READ", &path);
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| format!("读取文件失败 {path}: {e}"))?;
@@ -202,6 +219,7 @@ pub async fn assistant_read_file(path: String) -> Result<String, String> {
/// 写入文件
#[tauri::command]
pub async fn assistant_write_file(path: String, content: String) -> Result<String, String> {
audit_log("WRITE", &format!("{path} ({} bytes)", content.len()));
if let Some(parent) = PathBuf::from(&path).parent() {
tokio::fs::create_dir_all(parent)
.await

View File

@@ -119,7 +119,7 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
"maxProtocol": 3,
"client": {
"id": "openclaw-control-ui",
"version": "1.0.0",
"version": env!("CARGO_PKG_VERSION"),
"platform": platform,
"deviceFamily": device_family,
"mode": "ui"
@@ -136,7 +136,7 @@ pub fn create_connect_frame(nonce: String, gateway_token: String) -> Result<Valu
"signature": sig_b64,
},
"locale": "zh-CN",
"userAgent": "ClawPanel/1.0.0",
"userAgent": format!("ClawPanel/{}", env!("CARGO_PKG_VERSION")),
}
});

View File

@@ -1,4 +1,5 @@
use std::path::PathBuf;
use std::sync::OnceLock;
pub mod agent;
pub mod assistant;
@@ -15,12 +16,19 @@ pub fn openclaw_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".openclaw")
}
/// 缓存 enhanced_path 结果,避免每次调用都扫描文件系统
static ENHANCED_PATH_CACHE: OnceLock<String> = OnceLock::new();
/// Tauri 应用启动时 PATH 可能不完整:
/// - macOS 从 Finder 启动时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
/// - Windows 上安装 Node.js 到非默认路径、或安装后未重启进程
///
/// 补充 Node.js / npm 常见安装路径
pub fn enhanced_path() -> String {
ENHANCED_PATH_CACHE.get_or_init(build_enhanced_path).clone()
}
fn build_enhanced_path() -> String {
let current = std::env::var("PATH").unwrap_or_default();
let home = dirs::home_dir().unwrap_or_default();

View File

@@ -115,8 +115,14 @@ fn patch_gateway_origins() {
return;
};
// 放行全部 origin确保 Tauri 正式/开发模式、Web 模式都能连接
let origins = serde_json::json!(["*"]);
// 仅允许 Tauri 应用 + 本地开发服务器的 origin
let origins = serde_json::json!([
"tauri://localhost",
"https://tauri.localhost",
"http://tauri.localhost",
"http://localhost:1420",
"http://127.0.0.1:1420"
]);
if let Some(obj) = config.as_object_mut() {
let gateway = obj

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.5.5",
"version": "0.5.6",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",