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: