fix(security): 第二轮全量 API 审计修复(角色穿越/宿主 GUI/限流收口)

背景:对全部 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 组样本符合预期。端点级行为待服务重启后实测。

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
This commit is contained in:
JOJO 2026-09-02 19:37:34 +08:00
parent bc46250ef4
commit 0522414a6c
11 changed files with 92 additions and 5 deletions

View File

@ -7,6 +7,11 @@ Keys are prefixed with ``conversation.`` (the core table already owns
"""
MESSAGES = {
# ── _get_conversation_file_path对话 ID 白名单校验) ──
"conversation.invalid_conversation_id": {
"zh-CN": "对话 ID 不合法",
"en-US": "Invalid conversation ID",
},
# ── _build_safe_load_result安全导航加载结果返还前端 ──
"conversation.load_failed_not_found": {
"zh-CN": "对话不存在或加载失败",

View File

@ -30,6 +30,10 @@ MESSAGES = {
"zh-CN": "role_id/name/body_prompt 必填",
"en-US": "role_id/name/body_prompt are required",
},
"multi_agent_api.role_id_invalid": {
"zh-CN": "角色 ID 不合法:{role_id}(仅允许小写字母/数字开头,后接小写字母/数字/连字符/下划线)",
"en-US": "Invalid role ID: {role_id} (must start with lowercase letter/digit, followed by lowercase letters/digits/hyphens/underscores)",
},
"multi_agent_api.cannot_override_preset_role": {
"zh-CN": "不能覆盖预设角色 {role_id}",
"en-US": "Cannot override preset role {role_id}",

View File

@ -33,8 +33,22 @@ except ImportError:
CUSTOM_ROLES_DIR = ""
WEB_PRESET_ROLES_DIR = ""
from modules.i18n import tr
FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n(?P<rest>.*)$", re.S)
# 角色 ID 白名单:小写字母数字开头,允许连字符/下划线(与现有 ui-operator 等预设兼容)。
# role_id 直接参与文件路径拼接({role_id}.md必须拒绝 /、.. 等路径字符。
_ROLE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def validate_role_id(role_id: str) -> str:
"""校验并返回合法 role_id非法时 raise ValueError防路径穿越写入"""
cleaned = (role_id or "").strip()
if not _ROLE_ID_RE.match(cleaned):
raise ValueError(tr("multi_agent_api.role_id_invalid", role_id=repr(cleaned)))
return cleaned
# ----- 目录推断 -----
@ -363,6 +377,7 @@ def save_custom_role(
custom_dir: Optional[str | Path] = None,
) -> Path:
"""保存角色到自定义目录。不传 custom_dir 时回退到 host 运行态目录。"""
validate_role_id(role.role_id)
if custom_dir:
c_dir = Path(custom_dir).expanduser().resolve()
else:
@ -378,6 +393,7 @@ def delete_custom_role(
custom_dir: Optional[str | Path] = None,
) -> bool:
"""删除用户自定义角色。不传 custom_dir 时回退到 host 运行态目录。"""
role_id = validate_role_id(role_id)
if custom_dir:
c_dir = Path(custom_dir).expanduser().resolve()
else:

View File

@ -117,6 +117,7 @@ def admin_secondary_status():
@admin_bp.route('/api/admin/secondary/verify', methods=['POST'])
@login_required
@admin_required
@rate_limited("admin_secondary_verify", 5, 300, scope="user")
def admin_secondary_verify():
payload = request.get_json() or {}
password = str(payload.get("password") or "").strip()

View File

@ -57,6 +57,7 @@ from modules.personalization_manager import (
save_personalization_config,
)
from modules.upload_security import UploadSecurityError
from server.security import rate_limited
from modules.user_manager import UserWorkspace
from modules.usage_tracker import QUOTA_DEFAULTS
from modules.sub_agent import TERMINAL_STATUSES
@ -1220,12 +1221,14 @@ def download_conversation_media(media_id, terminal: WebTerminal, workspace: User
return jsonify({"success": False, "error": tr("conversation.media_file_not_found")}), 404
mime_type = str(entry.get("mime_type") or "application/octet-stream").strip() or "application/octet-stream"
# 可执行/脚本类 mime 一律强制下载,防止存储型 XSS与 file/content 的 SVG 策略一致)
_dangerous_inline = mime_type in {"text/html", "image/svg+xml", "application/xhtml+xml"} or mime_type.startswith("text/html")
blob_name = str(entry.get("blob_rel_path") or "")
filename = Path(blob_name).name if blob_name else target_id.replace(":", "_")
return send_file(
BytesIO(payload),
mimetype=mime_type,
as_attachment=False,
mimetype="application/octet-stream" if _dangerous_inline else mime_type,
as_attachment=_dangerous_inline,
download_name=filename,
conditional=True,
etag=True,
@ -1724,6 +1727,7 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
@conversation_bp.route('/api/conversations/<conversation_id>/compress', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("conversation_compress", 5, 300, scope="user")
def compress_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""深层压缩指定对话in-place生成 compact 文件、标记历史前缀为已压缩,按设置决定是否续接。"""
try:

View File

@ -24,6 +24,7 @@ from modules.multi_agent.role_store import (
delete_custom_role,
is_preset_role,
sync_preset_roles,
validate_role_id,
)
from config.paths import CUSTOM_ROLES_DIR, WEB_PRESET_ROLES_DIR
from config.limits import REASONING_EFFORT_LEVELS
@ -121,6 +122,10 @@ def create_role_api():
model_key = str(data.get("model_key") or "").strip() or None
if not role_id or not name or not body_prompt:
return jsonify({"success": False, "error": tr("multi_agent_api.role_fields_required")}), 400
try:
role_id = validate_role_id(role_id)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
if thinking_mode not in {"fast", "thinking"}:
thinking_mode = "fast"
# 不允许覆盖预设角色
@ -150,6 +155,10 @@ def create_role_api():
@api_login_required
def update_role_api(role_id: str):
"""更新用户自定义角色。不能更新预设角色。"""
try:
role_id = validate_role_id(role_id)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
try:
data = request.get_json() or {}
runtime_dir, custom_dir = _get_role_dirs()
@ -183,6 +192,10 @@ def update_role_api(role_id: str):
@api_login_required
def delete_role_api(role_id: str):
"""删除用户自定义角色。不能删除预设角色。"""
try:
role_id = validate_role_id(role_id)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
try:
if is_preset_role(role_id):
return jsonify({"success": False, "error": tr("multi_agent_api.cannot_delete_preset_role", role_id=role_id)}), 403

View File

@ -345,9 +345,36 @@ def handle_message(data):
start_chat_task(terminal, message, images, send_with_activity, client_sid, workspace, username, videos)
# WS 客户端日志事件的轻量限流防已认证用户洪泛写盘username -> 时间戳滑窗)
_WS_CLIENT_LOG_LIMIT = 30 # 每 60 秒最多 30 条
_WS_CLIENT_LOG_WINDOW = 60.0
_ws_client_log_buckets: Dict[str, list] = {}
_WS_CLIENT_LOG_MAX_KEYS = 10000
def _ws_client_log_allowed(username: str) -> bool:
now = time.time()
if len(_ws_client_log_buckets) > _WS_CLIENT_LOG_MAX_KEYS:
# 全局回收:清空过老桶(简单策略,防止桶表无限膨胀)
for key in [k for k, v in _ws_client_log_buckets.items() if not v or now - v[-1] > _WS_CLIENT_LOG_WINDOW]:
_ws_client_log_buckets.pop(key, None)
if len(_ws_client_log_buckets) > _WS_CLIENT_LOG_MAX_KEYS:
_ws_client_log_buckets.clear()
bucket = _ws_client_log_buckets.setdefault(username, [])
while bucket and now - bucket[0] > _WS_CLIENT_LOG_WINDOW:
bucket.pop(0)
if len(bucket) >= _WS_CLIENT_LOG_LIMIT:
return False
bucket.append(now)
return True
@socketio.on('client_chunk_log')
def handle_client_chunk_log(data):
"""前端chunk日志上报"""
username = connection_users.get(request.sid)
if not username or not _ws_client_log_allowed(username):
return
conversation_id = data.get('conversation_id')
chunk_index = int(data.get('index') or data.get('chunk_index') or 0)
elapsed = float(data.get('elapsed') or 0.0)
@ -359,6 +386,9 @@ def handle_client_chunk_log(data):
@socketio.on('client_stream_debug_log')
def handle_client_stream_debug_log(data):
"""前端流式调试日志"""
username = connection_users.get(request.sid)
if not username or not _ws_client_log_allowed(username):
return
if not isinstance(data, dict):
return
entry = dict(data)

View File

@ -21,7 +21,7 @@ from server.state import (
container_manager,
user_manager,
)
from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE
from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE, TERMINAL_SANDBOX_MOUNT_PATH
from modules.host_workspace_manager import (
create_host_workspace,
delete_host_workspace,
@ -149,7 +149,12 @@ def get_status(terminal, workspace, username):
except Exception as exc:
log_conn_diag(f"status conversation-meta-failed user={username} error={exc}")
timings["conversation_meta_ms"] = (time.perf_counter() - phase_started_at) * 1000
# docker/web 多用户模式下,宿主绝对路径会泄露服务器目录布局与系统用户名,
# 对用户无实际意义(操作均在容器 /workspace 内),脱敏为容器视角路径。
if _is_host_mode_request():
status['project_path'] = str(workspace.project_path)
else:
status['project_path'] = TERMINAL_SANDBOX_MOUNT_PATH
phase_started_at = time.perf_counter()
try:
# 首屏状态只需要容器是否存在/运行等轻量信息Docker stats 很慢,

View File

@ -327,6 +327,10 @@ def _open_file_with_app(file_path: Path, app_id: str) -> bool:
@api_login_required
@with_terminal
def open_project_in_file_manager(terminal, workspace, username):
# 在宿主 GUI 上弹文件管理器窗口——仅 host单机模式合理
# docker/web 多用户模式下这会让任何登录用户在服务器桌面弹窗(骚扰 + 路径存在性探测)
if not _is_host_mode_request():
return jsonify({"success": False, "error": tr("status_file_open.host_mode_only")}), 403
data = request.get_json(silent=True) or {}
target_workspace_id = (data.get("workspace_id") or "").strip()

View File

@ -17,6 +17,7 @@ from flask import current_app, session
from server.auth_helpers import api_login_required, get_current_username
from server.context import get_user_resources, ensure_conversation_loaded
from server.chat_flow import run_chat_task_sync
from server.security import rate_limited
from server.state import stop_flags
from server.utils_common import debug_log, log_conn_diag
from utils.host_workspace_debug import write_host_workspace_debug
@ -109,6 +110,7 @@ def get_conversation_running_status_api(conversation_id: str):
@tasks_bp.route("/api/tasks", methods=["POST"])
@api_login_required
@rate_limited("chat_task_create", 30, 60, scope="user")
def create_task_api():
username = get_current_username()
workspace_id = session.get("workspace_id") or "default"

View File

@ -50,7 +50,10 @@ class MetadataMixin:
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: