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:
parent
fd5360f210
commit
57043b0084
@ -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": "对话不存在或加载失败",
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 很慢,
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user