背景:对全部 206 个 HTTP 路由 + SocketIO 事件逐个排查(主审 + 2 个子
智能体初筛后复核),另按用户要求专项审计前端渲染面(无新发现)。
完整报告见 _experiments/security_audit_2026-09-02/REPORT.md 第八章。
新漏洞修复:
- 多智能体角色 role_id 路径穿越(高):save_custom_role 直接拼接
"{role_id}.md",POST body 的 role_id 无任何校验,可 ../ 穿越覆盖
其他用户角色文件(跨用户提示词注入),本地已复现写入成功。
修复:新增 validate_role_id 白名单(^[a-z0-9][a-z0-9_-]{0,63}$),
role_store 存储层 + multi_agent API 层(POST/PUT/DELETE)双保险
- open-in-file-manager 漏 _is_host_mode_request 检查(同文件其余三个
端点都有):docker 模式下任意登录用户可触发宿主 GUI 弹窗+探测路径。
修复:补 host 检查,docker 下 403
- /api/admin/secondary/verify 无限流且 CSRF 豁免:admin 会话前提下
可在线爆破二级密码(纵深防御缺失)。修复:5 次/300s 用户维度限流
- WS client_chunk_log / client_stream_debug_log 无连接认证检查且
无限流(日志写盘放大面)。修复:必须已认证连接 + 30 次/60s 滑窗
+ 桶表万级上限回收
- _get_conversation_file_path 直接拼 "{id}.json"(conv_ 前缀恰好阻碍
直接穿越,属防御深度缺失)。修复:^conv_[A-Za-z0-9_-]+$ 白名单,
已核验全部 5 个调用点与 temp_ 前缀排除路径不受影响
- /api/conversations/media/<id> 采信 entry mime_type 并 inline 返回。
修复:text/html、image/svg+xml、xhtml 强制 octet-stream + attachment
(与 file/content 的 SVG 策略对齐,防存储型 XSS 一致性收口)
- /api/status 每次心跳返回宿主绝对 project_path(泄露系统用户名与
目录布局)。修复:docker 模式脱敏为容器视角 /workspace,host 不变
LLM 成本攻击止血(第一轮发现 5 的端点级落地):
- POST /api/tasks(GUI 发消息主入口):30 次/60s/user
- POST /api/conversations/<id>/compress(调 api_client.chat 做摘要):
5 次/300s/user
- 注:对话回顾 review 端点实为纯本地 Markdown 生成(不调 LLM),
其「发送给模型」模式走 /api/tasks,已被上述限流覆盖;
按 token 计费的完整配额方案仍遗留待产品决策
验证:全部 py_compile 通过;冒烟测试 6/6;validate_role_id 8 个恶意
样本全拒 + 3 个合法样本放行 + 穿越写入拦截回归通过;conv_id 白名单
4 组样本符合预期。端点级行为待服务重启后实测。
187 lines
7.5 KiB
Python
187 lines
7.5 KiB
Python
# utils/conversation_manager.py - 对话持久化管理器(集成Token统计)
|
||
|
||
import json
|
||
import os
|
||
import time
|
||
import tempfile
|
||
import threading
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Any
|
||
from dataclasses import dataclass
|
||
from modules.i18n import tr
|
||
try:
|
||
from config import DATA_DIR, HOST_WORKSPACES_FILE
|
||
except ImportError:
|
||
import sys
|
||
from pathlib import Path
|
||
project_root = Path(__file__).resolve().parents[1]
|
||
if str(project_root) not in sys.path:
|
||
sys.path.insert(0, str(project_root))
|
||
from config import DATA_DIR, HOST_WORKSPACES_FILE
|
||
|
||
@dataclass
|
||
class ConversationMetadata:
|
||
"""对话元数据"""
|
||
id: str
|
||
title: str
|
||
created_at: str
|
||
updated_at: str
|
||
project_path: Optional[str]
|
||
project_relative_path: Optional[str]
|
||
thinking_mode: bool
|
||
total_messages: int
|
||
total_tools: int
|
||
run_mode: str = "fast"
|
||
model_key: Optional[str] = None
|
||
has_images: bool = False
|
||
has_videos: bool = False
|
||
status: str = "active" # active, archived, error
|
||
|
||
|
||
class MetadataMixin:
|
||
"""ConversationManager metadata mixin 能力 mixin。"""
|
||
|
||
def _generate_conversation_id(self) -> str:
|
||
"""生成唯一的对话ID"""
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
# 添加毫秒确保唯一性
|
||
ms = int(time.time() * 1000) % 1000
|
||
return f"conv_{timestamp}_{ms:03d}"
|
||
|
||
def _get_conversation_file_path(self, conversation_id: str) -> Path:
|
||
"""获取对话文件路径(白名单校验 ID,防止路径穿越读写数据目录外的文件)。"""
|
||
import re
|
||
if not re.fullmatch(r"conv_[A-Za-z0-9_-]+", conversation_id or ""):
|
||
raise ValueError(tr("conversation.invalid_conversation_id"))
|
||
return self.conversations_dir / f"{conversation_id}.json"
|
||
|
||
def _extract_title_from_messages(self, messages: List[Dict]) -> str:
|
||
"""从消息中提取标题"""
|
||
# 找到第一个用户消息作为标题
|
||
for msg in messages:
|
||
if msg.get("role") == "user":
|
||
metadata = msg.get("metadata") or {}
|
||
source = str(metadata.get("source") or metadata.get("message_source") or "").strip().lower()
|
||
if source == "skill" or metadata.get("hidden") is True:
|
||
continue
|
||
content = msg.get("content", "").strip()
|
||
if content:
|
||
# 取前50个字符作为标题
|
||
title = content[:50]
|
||
if len(content) > 50:
|
||
title += "..."
|
||
return title
|
||
return tr("conversation.default_title")
|
||
|
||
def _extract_text_content(self, content: Any) -> str:
|
||
"""从字符串/多模态列表/字典中提取纯文本。"""
|
||
if isinstance(content, str):
|
||
return content
|
||
if isinstance(content, list):
|
||
parts: List[str] = []
|
||
for item in content:
|
||
if isinstance(item, str):
|
||
parts.append(item)
|
||
elif isinstance(item, dict):
|
||
if item.get("type") == "text":
|
||
parts.append(str(item.get("text") or ""))
|
||
elif isinstance(item.get("text"), str):
|
||
parts.append(item.get("text") or "")
|
||
return "".join(parts)
|
||
if isinstance(content, dict):
|
||
return str(content.get("text") or "")
|
||
return ""
|
||
|
||
def _extract_first_user_message(self, messages: List[Dict], max_chars: int = 100) -> str:
|
||
"""提取首条用户消息纯文本,用于最近对话/搜索展示。"""
|
||
for msg in messages or []:
|
||
if msg.get("role") != "user":
|
||
continue
|
||
text = " ".join(self._extract_text_content(msg.get("content")).split())
|
||
if not text:
|
||
continue
|
||
if len(text) > max_chars:
|
||
return text[:max_chars] + "..."
|
||
return text
|
||
return ""
|
||
|
||
def _count_tools_in_messages(self, messages: List[Dict]) -> int:
|
||
"""统计消息中的工具调用数量(仅统计 assistant.tool_calls)。"""
|
||
tool_count = 0
|
||
for msg in messages:
|
||
if msg.get("role") == "assistant" and "tool_calls" in msg:
|
||
tool_calls = msg.get("tool_calls", [])
|
||
tool_count += len(tool_calls) if isinstance(tool_calls, list) else 0
|
||
return tool_count
|
||
|
||
def _prepare_project_path_metadata(self, project_path: Optional[str]) -> Dict[str, Optional[str]]:
|
||
"""
|
||
将项目路径规范化为绝对/相对形式,便于在不同机器间迁移
|
||
"""
|
||
normalized = {
|
||
"project_path": None,
|
||
"project_relative_path": None
|
||
}
|
||
|
||
if not project_path:
|
||
return normalized
|
||
|
||
try:
|
||
absolute_path = Path(project_path).expanduser().resolve()
|
||
normalized["project_path"] = str(absolute_path)
|
||
|
||
try:
|
||
relative_path = absolute_path.relative_to(self.workspace_root)
|
||
normalized["project_relative_path"] = relative_path.as_posix()
|
||
except ValueError:
|
||
normalized["project_relative_path"] = None
|
||
except Exception:
|
||
# 回退为原始字符串,至少不会阻止对话保存
|
||
normalized["project_path"] = str(project_path)
|
||
normalized["project_relative_path"] = None
|
||
|
||
return normalized
|
||
|
||
def _initialize_token_statistics(self) -> Dict:
|
||
"""初始化Token统计结构"""
|
||
now = datetime.now().isoformat()
|
||
return {
|
||
"total_input_tokens": 0,
|
||
"total_output_tokens": 0,
|
||
"total_tokens": 0,
|
||
"total_cached_input_tokens": 0,
|
||
# 豁免出命中率分母的冷启动输入累计(首轮未命中 + 压缩后首轮未命中的输入)
|
||
"cache_exempt_input_tokens": 0,
|
||
# 深度压缩后待判定标记:下一次真实调用若无缓存命中,其输入累加进豁免值
|
||
"cache_cold_start_pending": False,
|
||
"current_context_tokens": 0,
|
||
"updated_at": now
|
||
}
|
||
|
||
def _validate_token_statistics(self, data: Dict) -> Dict:
|
||
"""验证并修复Token统计数据"""
|
||
token_stats = data.get("token_statistics", {})
|
||
|
||
# 确保必要字段存在
|
||
defaults = self._initialize_token_statistics()
|
||
for key, default_value in defaults.items():
|
||
if key not in token_stats:
|
||
token_stats[key] = default_value
|
||
|
||
# 确保数值类型正确(cache_cold_start_pending 为布尔,不在此转换)
|
||
try:
|
||
token_stats["total_input_tokens"] = int(token_stats.get("total_input_tokens", 0))
|
||
token_stats["total_output_tokens"] = int(token_stats.get("total_output_tokens", 0))
|
||
token_stats["total_tokens"] = int(token_stats.get("total_tokens", 0))
|
||
token_stats["total_cached_input_tokens"] = int(token_stats.get("total_cached_input_tokens", 0))
|
||
token_stats["cache_exempt_input_tokens"] = int(token_stats.get("cache_exempt_input_tokens", 0))
|
||
token_stats["current_context_tokens"] = int(token_stats.get("current_context_tokens", 0))
|
||
token_stats["cache_cold_start_pending"] = bool(token_stats.get("cache_cold_start_pending", False))
|
||
except (ValueError, TypeError):
|
||
print("⚠️ Token统计数据损坏,重置为0")
|
||
token_stats = defaults
|
||
|
||
data["token_statistics"] = token_stats
|
||
return data
|