diff --git a/modules/i18n_messages/api_conversation.py b/modules/i18n_messages/api_conversation.py index cddbfb73..d8474099 100644 --- a/modules/i18n_messages/api_conversation.py +++ b/modules/i18n_messages/api_conversation.py @@ -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": "对话不存在或加载失败", diff --git a/modules/i18n_messages/api_multi_agent.py b/modules/i18n_messages/api_multi_agent.py index 58b05d4c..4139a939 100644 --- a/modules/i18n_messages/api_multi_agent.py +++ b/modules/i18n_messages/api_multi_agent.py @@ -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}", diff --git a/modules/multi_agent/role_store.py b/modules/multi_agent/role_store.py index b0ad53a0..ef397041 100644 --- a/modules/multi_agent/role_store.py +++ b/modules/multi_agent/role_store.py @@ -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.*?)\n---\s*\n(?P.*)$", 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: diff --git a/server/admin.py b/server/admin.py index e522cdd4..4207ef14 100644 --- a/server/admin.py +++ b/server/admin.py @@ -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() diff --git a/server/conversation.py b/server/conversation.py index 8a8e3f9e..7e11fdcd 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -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//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: diff --git a/server/multi_agent.py b/server/multi_agent.py index 30bd7cd5..49e0cfa6 100644 --- a/server/multi_agent.py +++ b/server/multi_agent.py @@ -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 diff --git a/server/socket_handlers.py b/server/socket_handlers.py index 63959790..214752a3 100644 --- a/server/socket_handlers.py +++ b/server/socket_handlers.py @@ -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) diff --git a/server/status/base.py b/server/status/base.py index 60fb4c76..b91edcd1 100644 --- a/server/status/base.py +++ b/server/status/base.py @@ -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 - status['project_path'] = str(workspace.project_path) + # 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 很慢, diff --git a/server/status/file_open.py b/server/status/file_open.py index 1eee21af..ed434daa 100644 --- a/server/status/file_open.py +++ b/server/status/file_open.py @@ -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() diff --git a/server/tasks/api.py b/server/tasks/api.py index cf042d1a..112ad645 100644 --- a/server/tasks/api.py +++ b/server/tasks/api.py @@ -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" diff --git a/utils/conversation_manager/metadata_mixin.py b/utils/conversation_manager/metadata_mixin.py index 32a8f478..0f80d8b0 100644 --- a/utils/conversation_manager/metadata_mixin.py +++ b/utils/conversation_manager/metadata_mixin.py @@ -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: