fix: 修复 SSRF 重定向绕过 + 符号链接路径遍历 + TodoItemSummary 字段缺失

P0: src/tools/http_request.rs SSRF 重定向绕过
  - reqwest::Client 默认跟随最多 10 次重定向,is_private_host 仅检查初始 URL
  - 攻击者可用公网 URL 返回 302 → http://127.0.0.1/http://169.254.169.254/(云元数据端点)绕过防护访问内网
  - 修复:.redirect(reqwest::redirect::Policy::none()) 完全禁用重定向

P1: src/tools/file_read/write/edit.rs 符号链接路径遍历
  - resolve_path 用 starts_with 检查但未 canonicalize
  - 攻击者可在 allowed_dir 内创建指向 /etc/passwd 的符号链接绕过限制
  - 修复:对 resolved 和 allowed 均执行 canonicalize 后比较
  - file_read: 文件必须存在,canonicalize 失败直接报错
  - file_write/edit: 文件可能不存在,降级到父目录 canonicalize

P1: src/protocol/mod.rs + list_todos.rs TodoItemSummary 字段缺失
  - 后端 TodoItemSummary 仅返回 4 字段,前端期望 7 字段
  - 缺失 priority, created_at, updated_at,前端 TodoPanel 无法显示
    优先级和时间戳
  - 修复:struct 补齐 3 字段,list_todos 构造时传递完整字段
This commit is contained in:
oudecheng 2026-07-08 16:23:09 +08:00
parent 53a45ad7c4
commit 58f461c953
6 changed files with 66 additions and 12 deletions

View File

@ -76,6 +76,9 @@ impl CommandHandler for ListTodosCommandHandler {
id: r.id,
content: r.content,
status: r.status,
priority: r.priority,
created_at: r.created_at,
updated_at: r.updated_at,
created_by_message_id: r.created_by_message_id,
})
.collect();

View File

@ -88,6 +88,9 @@ pub struct TodoItemSummary {
pub id: String,
pub content: String,
pub status: String,
pub priority: String,
pub created_at: i64,
pub updated_at: i64,
pub created_by_message_id: Option<String>,
}

View File

@ -33,11 +33,31 @@ impl FileEditTool {
// Check directory restriction
if let Some(ref allowed) = self.allowed_dir {
let allowed_path = Path::new(allowed);
if !resolved.starts_with(allowed_path) {
// canonicalize both paths to resolve symlinks and prevent path traversal
// via symlinks inside allowed_dir pointing outside.
// For edit tool the target file may not exist yet; fall back to
// canonicalizing the parent directory.
let canonical_allowed = std::fs::canonicalize(allowed)
.map_err(|e| format!("Failed to canonicalize allowed dir '{}': {}", allowed, e))?;
let canonical_resolved = match std::fs::canonicalize(&resolved) {
Ok(c) => c,
Err(_) => {
// File doesn't exist yet; canonicalize parent directory
let parent = resolved.parent().ok_or_else(|| {
format!("Path '{}' has no parent directory", path)
})?;
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
format!("Failed to canonicalize parent directory of '{}': {}", path, e)
})?;
canonical_parent.join(resolved.file_name().ok_or_else(|| {
format!("Path '{}' has no file name component", path)
})?)
}
};
if !canonical_resolved.starts_with(&canonical_allowed) {
return Err(format!(
"Path '{}' is outside allowed directory '{}'",
path, allowed
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
path, canonical_resolved.display(), canonical_allowed.display()
));
}
}

View File

@ -37,11 +37,18 @@ impl FileReadTool {
// Check directory restriction
if let Some(ref allowed) = self.allowed_dir {
let allowed_path = Path::new(allowed);
if !resolved.starts_with(allowed_path) {
// canonicalize both paths to resolve symlinks and prevent path traversal
// via symlinks inside allowed_dir pointing outside
let canonical_allowed = std::fs::canonicalize(allowed)
.map_err(|e| format!("Failed to canonicalize allowed dir '{}': {}", allowed, e))?;
// For read tool, file must exist; canonicalize will fail for non-existent paths
// which is acceptable (returns error)
let canonical_resolved = std::fs::canonicalize(&resolved)
.map_err(|e| format!("Failed to canonicalize path '{}': {}", path, e))?;
if !canonical_resolved.starts_with(&canonical_allowed) {
return Err(format!(
"Path '{}' is outside allowed directory '{}'",
path, allowed
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
path, canonical_resolved.display(), canonical_allowed.display()
));
}
}

View File

@ -32,11 +32,31 @@ impl FileWriteTool {
// Check directory restriction
if let Some(ref allowed) = self.allowed_dir {
let allowed_path = Path::new(allowed);
if !resolved.starts_with(allowed_path) {
// canonicalize both paths to resolve symlinks and prevent path traversal
// via symlinks inside allowed_dir pointing outside.
// For write tool the target file may not exist yet; fall back to
// canonicalizing the parent directory.
let canonical_allowed = std::fs::canonicalize(allowed)
.map_err(|e| format!("Failed to canonicalize allowed dir '{}': {}", allowed, e))?;
let canonical_resolved = match std::fs::canonicalize(&resolved) {
Ok(c) => c,
Err(_) => {
// File doesn't exist yet; canonicalize parent directory
let parent = resolved.parent().ok_or_else(|| {
format!("Path '{}' has no parent directory", path)
})?;
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
format!("Failed to canonicalize parent directory of '{}': {}", path, e)
})?;
canonical_parent.join(resolved.file_name().ok_or_else(|| {
format!("Path '{}' has no file name component", path)
})?)
}
};
if !canonical_resolved.starts_with(&canonical_allowed) {
return Err(format!(
"Path '{}' is outside allowed directory '{}'",
path, allowed
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
path, canonical_resolved.display(), canonical_allowed.display()
));
}
}

View File

@ -311,6 +311,7 @@ impl Tool for HttpRequestTool {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(self.timeout_secs))
.redirect(reqwest::redirect::Policy::none())
.build()
{
Ok(c) => c,