From 1a4e5baa2327d6b4d3040e6f92810fc904caaf99 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 2 Sep 2026 18:31:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20Docker=20=E5=A4=9A=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=A8=A1=E5=BC=8F=E5=85=A8=E9=9D=A2=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=88=E5=AE=89=E5=85=A8=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 威胁模型:拥有普通账号的已登录用户攻击服务器。完整审计报告见 _experiments/security_audit_2026-09-02/(不入库),含实测复现记录。 严重/高危修复: - 彻底移除文件夹打包下载(/api/download/folder 端点删除,gui/api_v1 目录下载分支改 410):文件型符号链接会被宿主进程解析,实测可读宿主 任意文件(含 settings.json 全部 LLM 密钥),删除比补校验更彻底 - XFF 伪造防护:get_client_ip 仅信任 ASTRION_TRUSTED_PROXIES (默认空=不信 XFF);登录新增账号维度锁定(5 次失败锁 300s); 注册邀请码加爆破锁定;限流桶/失败表加 2 万键上限+GC 回收 - auth_debug.log 接入大小轮转;/api/client_debug_log 加限流+长度截断 - /host-login 增加 LINUX_SAFETY 检查且仅回环地址可用 容器加固: - 默认 cpus=1 / memory=1g,新增 pids-limit=512、memory-swap、 no-new-privileges - 新增每用户容器配额 MAX_ACTIVE_CONTAINERS_PER_USER(默认 3), 防止单用户占满全局容器池 api_v1: - prompts/personalizations 的 name 加白名单校验(^[A-Za-z0-9_-]{1,64}$), 修复路径穿越写入;对话元数据中的引用名同步加白名单 - workspaces/conversations/messages/upload 四端点加用户维度限流 - 消息体加 MAX_MESSAGE_CHARS 上限(默认 200000,/api/tasks 同步) - _path_within 加分隔符边界(修复 startswith 前缀碰撞) 其他: - SVG 预览强制 application/octet-stream(修存储型 XSS 漏网) - monitor_snapshot 缓存键加 username 维度(修跨用户快照读取) - ensure/delete_workspace 加 workspace_id 白名单+父目录二次核验 - delete_folder 拒绝删除工作区根(crud_mixin 与容器代理同步修) - GuiFileManager 死代码加名称校验防复活 - admin_dashboard 静态壳非 admin 访问一律 404 - /api/app/apk/latest 加登录校验+限流(原未认证可拉 133MB) 有意未修(见报告 7.3 遗留清单):容器 egress 过滤(部署层)、 --cap-drop ALL(怕破坏容器内工作流,已先上 no-new-privileges)、 API 用户/子智能体 LLM 按 token 计费(待产品决策)、str(exc) 收口、 WebSocket 限流、network_permission=restricted 容器语义。 验证:全部文件 py_compile 通过;冒烟测试 6/6 通过;关键新逻辑 (名称校验/路径边界/容器代理删除防护/限流回收)已单测级验证; 端点级行为待服务重启后实测复核(清单见报告 7.5)。 Co-authored-by: Astrion powered by Kimi-K3 --- .env.example | 12 +++ config/limits.py | 3 + config/terminal.py | 11 ++- modules/api_user_manager.py | 12 +++ modules/container_file_proxy.py | 8 +- modules/file_manager/crud_mixin.py | 10 ++- modules/gui_file_manager.py | 18 +++-- modules/i18n_messages/api_tasks.py | 4 + modules/i18n_messages/api_v1.py | 12 +++ modules/i18n_messages/file_manager.py | 4 + modules/i18n_messages/infra.py | 4 + modules/i18n_messages/managers.py | 4 + .../i18n_messages/server_core_leftovers.py | 4 + modules/user_container_manager.py | 19 +++++ server/api_v1.py | 64 ++++++++++++---- server/app_legacy.py | 9 ++- server/auth.py | 38 ++++++++-- server/chat/files.py | 47 ++---------- server/chat/misc.py | 2 +- server/chat_flow_tool_loop.py | 6 +- server/context.py | 7 ++ server/files.py | 15 +--- server/monitor.py | 23 ++++-- server/security.py | 74 +++++++++++++++++-- server/status/app.py | 9 +++ server/tasks/api.py | 3 + 26 files changed, 326 insertions(+), 96 deletions(-) diff --git a/.env.example b/.env.example index cb00a61f..cc902b08 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,10 @@ WEB_SERVER_PORT=8091 WEB_SERVER_HOST=0.0.0.0 # 调试模式(同时控制 Flask reloader),生产保持 0 WEB_SERVER_DEBUG=0 +# 可信反向代理列表(逗号分隔 IP,默认空=不信任任何 X-Forwarded-For) +# 仅当部署在 nginx/CF 等反代之后时才需要配置,例如 ASTRION_TRUSTED_PROXIES=127.0.0.1 +# 直连部署切勿配置,否则登录限流可被伪造的 XFF 绕过 +# ASTRION_TRUSTED_PROXIES= # === 运行环境二进制(便携 release 包用,开发环境留默认即可)================== # 子智能体使用的 node 可执行文件;留 "node" 表示走系统 PATH @@ -64,7 +68,11 @@ TERMINAL_SANDBOX_SHELL=/bin/bash TERMINAL_SANDBOX_NETWORK=bridge TERMINAL_SANDBOX_CPUS=0.5 TERMINAL_SANDBOX_MEMORY=1g +# 容器内进程数上限(防 fork bomb) +TERMINAL_SANDBOX_PIDS_LIMIT=512 # 附加绑定目录(逗号分隔,可留空) +# 安全红线:严禁绑定 /var/run/docker.sock、/、/etc、/home、/root、~/.ssh、 +# 数据根目录或源码目录——绑定 docker.sock 等于把宿主机 root 交给所有用户 TERMINAL_SANDBOX_BINDS= # 运行时路径及容器名称前缀 TERMINAL_SANDBOX_BIN=docker @@ -81,6 +89,10 @@ HOST_SANDBOX_MACOS_WRITABLE_PATHS= # === 资源控制 ================================================================ PROJECT_MAX_STORAGE_MB=2048 MAX_ACTIVE_USER_CONTAINERS=8 +# 单个用户最多同时保活的容器数(防单用户占满全局容器池) +MAX_ACTIVE_CONTAINERS_PER_USER=3 +# 单条消息最大字符数(防超大消息撑爆模型上下文/存储) +MAX_MESSAGE_CHARS=200000 # === MCP 工具扩展(可选) ===================================================== # 1=启用,0=禁用 diff --git a/config/limits.py b/config/limits.py index 81c7c473..dfecdb2d 100644 --- a/config/limits.py +++ b/config/limits.py @@ -13,6 +13,9 @@ CODE_EXECUTION_TIMEOUT = 60 TERMINAL_COMMAND_TIMEOUT = 120 SEARCH_MAX_RESULTS = 10 +# 单条用户消息最大字符数(防成本攻击与内存放大;2026-09-02 安全审计新增) +MAX_MESSAGE_CHARS = int(os.environ.get("MAX_MESSAGE_CHARS", "200000") or 200000) + # 自动修复与工具调用限制(None 表示不限制) AUTO_FIX_TOOL_CALL = False AUTO_FIX_MAX_ATTEMPTS = 3 diff --git a/config/terminal.py b/config/terminal.py index 8786dae8..47337ce1 100644 --- a/config/terminal.py +++ b/config/terminal.py @@ -39,8 +39,11 @@ TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-s TERMINAL_SANDBOX_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace") TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash") TERMINAL_SANDBOX_NETWORK = os.environ.get("TERMINAL_SANDBOX_NETWORK", "bridge") -TERMINAL_SANDBOX_CPUS = os.environ.get("TERMINAL_SANDBOX_CPUS", "") -TERMINAL_SANDBOX_MEMORY = os.environ.get("TERMINAL_SANDBOX_MEMORY", "") +# 安全默认:限制单容器 CPU/内存/PID,防止单用户资源耗尽型 DoS(2026-09-02 审计)。 +# 如需放开可显式设为空字符串(不推荐多用户部署放开)。 +TERMINAL_SANDBOX_CPUS = os.environ.get("TERMINAL_SANDBOX_CPUS", "1") +TERMINAL_SANDBOX_MEMORY = os.environ.get("TERMINAL_SANDBOX_MEMORY", "1g") +TERMINAL_SANDBOX_PIDS_LIMIT = os.environ.get("TERMINAL_SANDBOX_PIDS_LIMIT", "512") TERMINAL_SANDBOX_BINDS = _parse_bindings(os.environ.get("TERMINAL_SANDBOX_BINDS", "")) TERMINAL_SANDBOX_BIN = os.environ.get("TERMINAL_SANDBOX_BIN", "docker") TERMINAL_SANDBOX_NAME_PREFIX = os.environ.get("TERMINAL_SANDBOX_NAME_PREFIX", "agent-term") @@ -53,6 +56,8 @@ TERMINAL_SANDBOX_REQUIRE = os.environ.get("TERMINAL_SANDBOX_REQUIRE", "0") not i LINUX_SAFETY = os.environ.get("LINUX_SAFETY", "0") not in {"0", "false", "False"} TOOLBOX_TERMINAL_IDLE_SECONDS = int(os.environ.get("TOOLBOX_TERMINAL_IDLE_SECONDS", "900")) MAX_ACTIVE_USER_CONTAINERS = int(os.environ.get("MAX_ACTIVE_USER_CONTAINERS", "8")) +# 每用户同时活跃的容器上限(防单用户多工作区占满全局容器池,2026-09-02 审计新增) +MAX_ACTIVE_CONTAINERS_PER_USER = int(os.environ.get("MAX_ACTIVE_CONTAINERS_PER_USER", "3")) HOST_EXECUTION_MODE_DEFAULT = os.environ.get("HOST_EXECUTION_MODE_DEFAULT", "sandbox").strip().lower() # 沙箱可写路径的「部署通道」(逗号分隔)。路径授权只有两个来源: # config/host_sandbox_policy.json(前端「路径授权」UI)+ 本变量(真·环境变量), @@ -83,6 +88,7 @@ __all__ = [ "TERMINAL_SANDBOX_NETWORK", "TERMINAL_SANDBOX_CPUS", "TERMINAL_SANDBOX_MEMORY", + "TERMINAL_SANDBOX_PIDS_LIMIT", "TERMINAL_SANDBOX_BINDS", "TERMINAL_SANDBOX_BIN", "TERMINAL_SANDBOX_NAME_PREFIX", @@ -91,6 +97,7 @@ __all__ = [ "LINUX_SAFETY", "TOOLBOX_TERMINAL_IDLE_SECONDS", "MAX_ACTIVE_USER_CONTAINERS", + "MAX_ACTIVE_CONTAINERS_PER_USER", "HOST_EXECUTION_MODE_DEFAULT", "HOST_SANDBOX_MACOS_WRITABLE_PATHS", "HOST_SANDBOX_NETWORK_PERMISSION", diff --git a/modules/api_user_manager.py b/modules/api_user_manager.py index 6b43a8c2..e8e0289b 100644 --- a/modules/api_user_manager.py +++ b/modules/api_user_manager.py @@ -115,6 +115,10 @@ class ApiUserManager: ws_id = (workspace_id or "default").strip() if not ws_id: ws_id = "default" + # 安全:workspace_id 参与路径拼接,必须严格白名单(防 `..`/斜杠穿越) + import re as _re + if not _re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,39}", ws_id) or ".." in ws_id: + raise ValueError(tr("api_user_mgr.invalid_workspace_id")) user_root = (self.workspace_root / username).resolve() shared_dir = user_root / "shared" @@ -284,7 +288,15 @@ class ApiUserManager: ws_id = (workspace_id or "").strip() if not ws_id: return False + # 安全:同 ensure_workspace 的严格白名单校验,防路径穿越删除任意目录 + import re as _re + if not _re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,39}", ws_id) or ".." in ws_id: + return False work_root = (self.workspace_root / username / "workspaces" / ws_id).resolve() + # 二次防护:解析后的落点必须严格位于该用户的 workspaces 目录内 + expected_parent = (self.workspace_root / username / "workspaces").resolve() + if work_root.parent != expected_parent: + return False if not work_root.exists(): return False import shutil diff --git a/modules/container_file_proxy.py b/modules/container_file_proxy.py index 064b434a..48e0135a 100644 --- a/modules/container_file_proxy.py +++ b/modules/container_file_proxy.py @@ -19,7 +19,8 @@ import shutil def _resolve(root: pathlib.Path, rel: str) -> pathlib.Path: base = root.resolve() target = (base / rel).resolve() - if not str(target).startswith(str(base)): + # 带分隔符边界的包含判断,防 startswith 前缀碰撞(如 /workspace-evil) + if target != base and not str(target).startswith(str(base) + __import__('os').sep): raise ValueError("路径越界: %s" % rel) return target @@ -82,7 +83,12 @@ def _create_folder(root, payload): def _delete_folder(root, payload): rel = payload.get("path") + # 安全:禁止删除工作区根本身(防 rmtree 抹掉整个 bind-mount 工作区) + if not rel or str(rel).strip() in {"", ".", "./", ".."}: + return {"success": False, "error": "不允许删除工作区根目录"} target = _resolve(root, rel) + if target == root.resolve(): + return {"success": False, "error": "不允许删除工作区根目录"} if not target.exists(): return {"success": False, "error": "文件夹不存在"} if not target.is_dir(): diff --git a/modules/file_manager/crud_mixin.py b/modules/file_manager/crud_mixin.py index d0d5cd47..a363f8ed 100644 --- a/modules/file_manager/crud_mixin.py +++ b/modules/file_manager/crud_mixin.py @@ -209,7 +209,15 @@ class CrudMixin: valid, error, full_path = self._validate_path(path) if not valid: return {"success": False, "error": error} - + + # 安全:禁止删除工作区根目录本身(空路径/点路径会解析为根; + # docker 模式下该目录是宿主 bind mount,rmtree 会真实删除宿主文件) + try: + if full_path.resolve() == Path(self.project_path).resolve(): + return {"success": False, "error": tr("file_manager.cannot_delete_workspace_root")} + except Exception: + pass + if not full_path.exists(): return {"success": False, "error": tr("file_manager.folder_not_found")} diff --git a/modules/gui_file_manager.py b/modules/gui_file_manager.py index a8047a26..1de556f3 100644 --- a/modules/gui_file_manager.py +++ b/modules/gui_file_manager.py @@ -167,6 +167,15 @@ class GuiFileManager: # 基本操作 # ------------------------- + def _sanitize_entry_name(self, name: str) -> str: + """条目名称段级校验:禁止路径分隔符与穿越片段(防 `../../` 越出工作区)。""" + sanitized = (name or "").strip() + if not sanitized: + raise ValueError(tr("gui_file.name_empty")) + if sanitized in {".", ".."} or "/" in sanitized or "\\" in sanitized or "\x00" in sanitized: + raise ValueError(tr("gui_file.name_empty")) + return sanitized + def create_entry(self, parent_relative: Optional[str], name: str, entry_type: str) -> str: parent = self._resolve(parent_relative) if not parent.exists(): @@ -174,9 +183,7 @@ class GuiFileManager: if not parent.is_dir(): raise NotADirectoryError(tr("gui_file.parent_not_directory")) - sanitized = name.strip() - if not sanitized: - raise ValueError(tr("gui_file.name_empty")) + sanitized = self._sanitize_entry_name(name) target = parent / sanitized if target.exists(): @@ -215,8 +222,9 @@ class GuiFileManager: raise FileNotFoundError(tr("gui_file.target_not_found")) parent = target.parent - sanitized = new_name.strip() - if not sanitized: + try: + sanitized = self._sanitize_entry_name(new_name) + except ValueError: raise ValueError(tr("gui_file.new_name_empty")) new_path = parent / sanitized if new_path.exists(): diff --git a/modules/i18n_messages/api_tasks.py b/modules/i18n_messages/api_tasks.py index 5b860895..03135563 100644 --- a/modules/i18n_messages/api_tasks.py +++ b/modules/i18n_messages/api_tasks.py @@ -16,6 +16,10 @@ MESSAGES = { "zh-CN": "消息不能为空", "en-US": "Message cannot be empty", }, + "tasks.message_too_long": { + "zh-CN": "消息内容过长", + "en-US": "Message is too long", + }, "tasks.workspace_unavailable": { "zh-CN": "工作区不可用", "en-US": "Workspace is unavailable", diff --git a/modules/i18n_messages/api_v1.py b/modules/i18n_messages/api_v1.py index 6ba37567..36bdeac4 100644 --- a/modules/i18n_messages/api_v1.py +++ b/modules/i18n_messages/api_v1.py @@ -111,4 +111,16 @@ MESSAGES = { "zh-CN": "保存失败: {error}", "en-US": "Failed to save: {error}", }, + "api_v1.invalid_resource_name": { + "zh-CN": "名称只能包含字母、数字、下划线和连字符(最长64字符)", + "en-US": "Name may only contain letters, digits, underscores and hyphens (max 64 chars)", + }, + "api_v1.folder_download_removed": { + "zh-CN": "文件夹打包下载功能已下线", + "en-US": "Folder archive download has been removed", + }, + "api_v1.message_too_long": { + "zh-CN": "消息内容过长", + "en-US": "Message is too long", + }, } \ No newline at end of file diff --git a/modules/i18n_messages/file_manager.py b/modules/i18n_messages/file_manager.py index 63491c11..1d2cf15c 100644 --- a/modules/i18n_messages/file_manager.py +++ b/modules/i18n_messages/file_manager.py @@ -154,6 +154,10 @@ MESSAGES = { "zh-CN": "文件夹不存在", "en-US": "Folder not found", }, + "file_manager.cannot_delete_workspace_root": { + "zh-CN": "不允许删除工作区根目录", + "en-US": "Deleting the workspace root is not allowed", + }, "file_manager.not_a_folder": { "zh-CN": "不是文件夹", "en-US": "Not a folder", diff --git a/modules/i18n_messages/infra.py b/modules/i18n_messages/infra.py index a6f4bb52..d3776b13 100644 --- a/modules/i18n_messages/infra.py +++ b/modules/i18n_messages/infra.py @@ -219,6 +219,10 @@ MESSAGES = { "zh-CN": "资源繁忙:容器配额已用尽,请稍候再试。", "en-US": "Busy: container quota exhausted, please try again later", }, + "container_mgr.per_user_quota_exhausted": { + "zh-CN": "资源繁忙:您的容器数量已达上限({limit} 个),请先释放其他工作区的容器。", + "en-US": "Busy: you have reached your container limit ({limit}); please release containers of other workspaces first", + }, "container_mgr.runtime_not_found": { "zh-CN": "未找到容器运行时 {runtime}", "en-US": "Container runtime not found: {runtime}", diff --git a/modules/i18n_messages/managers.py b/modules/i18n_messages/managers.py index 6a2d2ff9..73e653c9 100644 --- a/modules/i18n_messages/managers.py +++ b/modules/i18n_messages/managers.py @@ -140,6 +140,10 @@ MESSAGES = { "zh-CN": "用户名不能为空", "en-US": "Username cannot be empty", }, + "api_user_mgr.invalid_workspace_id": { + "zh-CN": "workspace_id 只能包含字母、数字、点、下划线或连字符(1-40 位,以字母或数字开头)", + "en-US": "workspace_id may only contain letters, digits, dots, underscores or hyphens (1-40 chars, starting with a letter or digit)", + }, "api_user_mgr.users_file_parse_failed": { "zh-CN": "无法解析 API 用户文件: {file_path} ({error})", "en-US": "Failed to parse API user file: {file_path} ({error})", diff --git a/modules/i18n_messages/server_core_leftovers.py b/modules/i18n_messages/server_core_leftovers.py index 9a6f8347..cf420d3b 100644 --- a/modules/i18n_messages/server_core_leftovers.py +++ b/modules/i18n_messages/server_core_leftovers.py @@ -34,6 +34,10 @@ MESSAGES = { "zh-CN": "缺少 path", "en-US": "Missing path", }, + "files.folder_download_removed": { + "zh-CN": "文件夹打包下载功能已下线", + "en-US": "Folder archive download has been removed", + }, "files.missing_path_or_content": { "zh-CN": "缺少 path 或 content", "en-US": "Missing path or content", diff --git a/modules/user_container_manager.py b/modules/user_container_manager.py index 61bc4234..44b6097a 100644 --- a/modules/user_container_manager.py +++ b/modules/user_container_manager.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import Dict, Optional from config import ( + MAX_ACTIVE_CONTAINERS_PER_USER, MAX_ACTIVE_USER_CONTAINERS, OUTPUT_FORMATS, TERMINAL_SANDBOX_BIN, @@ -27,6 +28,7 @@ from config import ( TERMINAL_SANDBOX_MOUNT_PATH, TERMINAL_SANDBOX_NAME_PREFIX, TERMINAL_SANDBOX_NETWORK, + TERMINAL_SANDBOX_PIDS_LIMIT, TERMINAL_SANDBOX_REQUIRE, LOGS_DIR, LINUX_SAFETY, @@ -80,6 +82,7 @@ class UserContainerManager: self.network = TERMINAL_SANDBOX_NETWORK self.cpus = TERMINAL_SANDBOX_CPUS self.memory = TERMINAL_SANDBOX_MEMORY + self.pids_limit = TERMINAL_SANDBOX_PIDS_LIMIT self.binds = list(TERMINAL_SANDBOX_BINDS) self.sandbox_bin = TERMINAL_SANDBOX_BIN or "docker" self.name_prefix = TERMINAL_SANDBOX_NAME_PREFIX or "agent-user" @@ -158,6 +161,16 @@ class UserContainerManager: if not self._has_capacity(key): raise RuntimeError(tr("container_mgr.quota_exhausted")) + # 每用户容器配额:防单用户多工作区占满全局容器池,挤占其他用户 + per_user_limit = MAX_ACTIVE_CONTAINERS_PER_USER + if per_user_limit > 0: + owned = sum( + 1 for k in self._containers + if k == username_norm or k.startswith(f"{username_norm}::") + ) + if owned >= per_user_limit: + raise RuntimeError(tr("container_mgr.per_user_quota_exhausted", limit=per_user_limit)) + # Important: create container using the cache key so each workspace gets its own container name. handle = self._create_handle(key, workspace, mode) self._containers[key] = handle @@ -327,6 +340,12 @@ class UserContainerManager: cmd += ["--cpus", str(self.cpus)] if self.memory: cmd += ["--memory", str(self.memory)] + # 锁定 swap 用量等于内存上限,防止 swap 绕过内存限制 + cmd += ["--memory-swap", str(self.memory)] + # 安全默认(2026-09-02 审计):PID 上限防 fork bomb;禁提权防 setuid 类攻击。 + if self.pids_limit: + cmd += ["--pids-limit", str(self.pids_limit)] + cmd += ["--security-opt", "no-new-privileges:true"] for bind in self.binds: chunk = bind.strip() if chunk: diff --git a/server/api_v1.py b/server/api_v1.py index c24afc83..a834adfb 100644 --- a/server/api_v1.py +++ b/server/api_v1.py @@ -2,14 +2,13 @@ from __future__ import annotations import os import json -import zipfile -from io import BytesIO from pathlib import Path from typing import Dict, Any, Optional from flask import Blueprint, request, jsonify, send_file, session from .api_auth import api_token_required +from .security import rate_limited from .tasks import task_manager from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides from .utils_common import sanitize_filename_preserve_unicode @@ -69,6 +68,7 @@ def list_workspaces_api(): @api_v1_bp.route("/workspaces", methods=["POST"]) @api_token_required +@rate_limited("api_v1_create_ws", 10, 300, scope="user") def create_workspace_api(): username = session.get("username") payload = request.get_json(silent=True) or {} @@ -120,6 +120,25 @@ def delete_workspace_api(workspace_id: str): return jsonify({"success": True, "workspace_id": ws_id}) +def _path_within(base: Path, target: Path) -> bool: + """严格的路径包含判断(带分隔符边界),避免 startswith 前缀碰撞。""" + if target == base: + return True + return str(target).startswith(str(base) + os.sep) + + +def _validate_resource_name(name: str) -> str: + """校验 prompts/personalizations 等资源名,防路径穿越写入。 + + 仅允许字母数字/下划线/连字符,最长 64;不含点号(防 `..` 与后缀伪装)。 + """ + import re + candidate = (name or "").strip() + if not candidate or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", candidate): + raise ValueError(tr("api_v1.invalid_resource_name")) + return candidate + + def _within_uploads(workspace, rel_path: str) -> Path: base = Path(workspace.uploads_dir).resolve() rel = rel_path or "" @@ -128,7 +147,7 @@ def _within_uploads(workspace, rel_path: str) -> Path: rel = rel.split("user_upload/", 1)[1] rel = rel.lstrip("/").strip() target = (base / rel).resolve() - if not str(target).startswith(str(base)): + if not _path_within(base, target): raise ValueError(tr("api_v1_raise.invalid_path")) return target @@ -151,7 +170,7 @@ def _within_project(workspace, rel_path: str, default_to_project_root: bool = Tr rel = rel.split("/workspace/", 1)[1] rel = rel.lstrip("/") target = (base / rel).resolve() - if not str(target).startswith(str(base)): + if not _path_within(base, target): raise ValueError(tr("api_v1_raise.invalid_path")) return target @@ -207,6 +226,7 @@ def _resolve_workspace(username: str, workspace_id: str): @api_v1_bp.route("/workspaces//conversations", methods=["POST"]) @api_token_required +@rate_limited("api_v1_create_conv", 30, 60, scope="user") def create_conversation_api(workspace_id: str): username = session.get("username") ws = _resolve_workspace(username, workspace_id) @@ -233,11 +253,15 @@ def create_conversation_api(workspace_id: str): @api_v1_bp.route("/workspaces//messages", methods=["POST"]) @api_token_required +@rate_limited("api_v1_send_msg", 20, 60, scope="user") def send_message_api(workspace_id: str): username = session.get("username") ws = _resolve_workspace(username, workspace_id) payload = request.get_json() or {} message = (payload.get("message") or "").strip() + from config import MAX_MESSAGE_CHARS + if len(message) > MAX_MESSAGE_CHARS: + return jsonify({"success": False, "error": tr("api_v1.message_too_long")}), 400 images = payload.get("images") or [] conversation_id = payload.get("conversation_id") model_key = payload.get("model_key") @@ -260,11 +284,13 @@ def send_message_api(workspace_id: str): # 应用自定义 prompt/personalization(如果提供) try: if prompt_name: + prompt_name = _validate_resource_name(prompt_name) prompt_path = _prompt_dir(workspace) / f"{prompt_name}.txt" if not prompt_path.exists(): return jsonify({"success": False, "error": tr("api_v1.prompt_not_found")}), 404 terminal.context_manager.custom_system_prompt = prompt_path.read_text(encoding="utf-8") if personalization_name: + personalization_name = _validate_resource_name(personalization_name) pers_path = _personalization_dir(workspace) / f"{personalization_name}.json" if not pers_path.exists(): return jsonify({"success": False, "error": tr("api_v1.personalization_not_found")}), 404 @@ -427,6 +453,7 @@ def cancel_task_api_v1(task_id: str): @api_v1_bp.route("/workspaces//files/upload", methods=["POST"]) @api_token_required +@rate_limited("api_v1_upload", 20, 300, scope="user") def upload_file_api(workspace_id: str): username = session.get("username") ws = _resolve_workspace(username, workspace_id) @@ -538,16 +565,9 @@ def download_file_api(workspace_id: str): return jsonify({"success": False, "error": tr("api_v1.file_does_not_exist")}), 404 if target.is_dir(): - memory_file = BytesIO() - with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf: - for root, _, files in os.walk(target): - for file in files: - full_path = Path(root) / file - arcname = full_path.relative_to(workspace.project_path) - zf.write(full_path, arcname=str(arcname)) - memory_file.seek(0) - download_name = f"{target.name}.zip" - return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip') + # 文件夹打包下载已下线(2026-09-02 安全审计:os.walk/zipfile 跟随文件型 + # 符号链接,可泄露宿主任意文件) + return jsonify({"success": False, "error": tr("api_v1.folder_download_removed")}), 410 return send_file(target, as_attachment=True, download_name=target.name) @@ -580,6 +600,10 @@ def get_prompt_api(name: str): _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": tr("api_v1.system_not_initialized")}), 503 + try: + name = _validate_resource_name(name) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 p = _prompt_dir(workspace) / f"{name}.txt" if not p.exists(): return jsonify({"success": False, "error": tr("api_v1.prompt_not_found")}), 404 @@ -599,6 +623,10 @@ def create_prompt_api(): content = payload.get("content") or "" if not name: return jsonify({"success": False, "error": tr("api_v1.name_empty")}), 400 + try: + name = _validate_resource_name(name) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 p = _prompt_dir(workspace) / f"{name}.txt" p.write_text(content, encoding="utf-8") return jsonify({"success": True, "name": name}) @@ -632,6 +660,10 @@ def get_personalization_api(name: str): _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": tr("api_v1.system_not_initialized")}), 503 + try: + name = _validate_resource_name(name) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 p = _personalization_dir(workspace) / f"{name}.json" if not p.exists(): return jsonify({"success": False, "error": tr("api_v1.personalization_not_found")}), 404 @@ -661,6 +693,10 @@ def create_personalization_api(): return jsonify({"success": False, "error": tr("api_v1.name_empty")}), 400 if content is None: return jsonify({"success": False, "error": tr("api_v1.content_empty")}), 400 + try: + name = _validate_resource_name(name) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 p = _personalization_dir(workspace) / f"{name}.json" try: if not isinstance(content, dict): diff --git a/server/app_legacy.py b/server/app_legacy.py index 5ff5bdf1..f7265e1c 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -1142,9 +1142,16 @@ def resource_busy_page(): @app.route('/api/client_debug_log', methods=['POST']) def client_debug_log(): - """接收前端目标模式等调试日志""" + """接收前端目标模式等调试日志(限流 + 长度截断,防止未认证磁盘攻击)。""" + from server.security import check_rate_limit, get_client_ip + limited, retry_after = check_rate_limit("client_debug_log", 10, 60, get_client_ip()) + if limited: + return jsonify({'ok': False, 'error': 'rate_limited', 'retry_after': retry_after}), 429 try: data = request.get_json(silent=True) or {} + serialized = json.dumps(data, ensure_ascii=False) + if len(serialized) > 4000: # 单条硬截断,避免大 payload 灌入日志 + data = {"truncated": True, "raw_preview": serialized[:4000]} entry = dict(data) entry.setdefault('server_ts', time.time()) log_goal_mode_debug_entry(entry) diff --git a/server/auth.py b/server/auth.py index d0931731..d591899c 100644 --- a/server/auth.py +++ b/server/auth.py @@ -23,6 +23,7 @@ from .security import ( register_failure, is_action_blocked, clear_failures, + get_client_ip, ) from . import state from .utils_common import debug_log @@ -37,8 +38,9 @@ def auth_debug_log(message: str): ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") line = f"[{ts}] {message}" try: - with AUTH_DEBUG_FILE.open("a", encoding="utf-8") as f: - f.write(line + "\n") + # 走统一轮转,防止未认证请求刷爆磁盘(此前为无上限 append) + from utils.log_rotation import append_line + append_line(AUTH_DEBUG_FILE, line) except Exception: pass try: @@ -139,7 +141,9 @@ def login(): data = request.get_json() or {} email = (data.get('email') or '').strip() password = data.get('password') or '' - client_ip = request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or request.remote_addr or 'unknown' + # XFF 仅在可信代理后采信(见 security.get_client_ip),直连时以真实对端为准 + client_ip = get_client_ip() + account_key = (email or '').strip().lower() or 'unknown' limited, retry_after = check_rate_limit("login", 10, 60, client_ip) if limited: @@ -149,8 +153,14 @@ def login(): if blocked: return jsonify({"success": False, "error": tr("auth.too_many_attempts", seconds=block_for), "retry_after": block_for}), 429 + # 账号维度锁定:防止伪造/轮换来源 IP 对同一账号持续爆破 + blocked, block_for = is_action_blocked("login_account", identifier=account_key) + if blocked: + return jsonify({"success": False, "error": tr("auth.too_many_attempts", seconds=block_for), "retry_after": block_for}), 429 + record = state.user_manager.authenticate(email, password) if not record: + register_failure("login_account", state.FAILED_LOGIN_LIMIT, state.FAILED_LOGIN_LOCK_SECONDS, identifier=account_key) wait_seconds = register_failure("login", state.FAILED_LOGIN_LIMIT, state.FAILED_LOGIN_LOCK_SECONDS, identifier=client_ip) error_payload = {"success": False, "error": tr("auth.invalid_credentials")} status_code = 401 @@ -193,6 +203,7 @@ def login(): session.permanent = True _issue_login_nonce(record.username) clear_failures("login", identifier=client_ip) + clear_failures("login_account", identifier=account_key) try: state.container_manager.ensure_container( record.username, @@ -211,8 +222,15 @@ def login(): @auth_bp.route('/host-login', methods=['POST']) def host_login(): - """宿主机模式一键进入(仅当 TERMINAL_SANDBOX_MODE=host 时可用)。""" - if (TERMINAL_SANDBOX_MODE or "").lower() != "host": + """宿主机模式一键进入(仅当 TERMINAL_SANDBOX_MODE=host 时可用)。 + + 该入口无任何凭证,因此只允许本机回环地址直连调用(host 模式定位为本机单人使用); + 经反向代理/远程访问时一律拒绝,防止公网部署下任何人一键获得 admin 会话。 + """ + if (TERMINAL_SANDBOX_MODE or "").lower() != "host" or LINUX_SAFETY: + return jsonify({"success": False, "error": tr("auth.host_mode_disabled")}), 403 + remote_addr = (request.remote_addr or "").strip() + if remote_addr not in {"127.0.0.1", "::1", "localhost"}: return jsonify({"success": False, "error": tr("auth.host_mode_disabled")}), 403 if not state.container_manager.has_capacity("host"): return jsonify({"success": False, "error": tr("auth.resource_busy")}), 503 @@ -293,14 +311,22 @@ def register(): invite_code = (data.get('invite_code') or '').strip() from .security import get_client_ip - limited, retry_after = check_rate_limit("register", 5, 300, get_client_ip()) + client_ip = get_client_ip() + limited, retry_after = check_rate_limit("register", 5, 300, client_ip) if limited: return jsonify({"success": False, "error": tr("auth.register_rate_limited"), "retry_after": retry_after}), 429 + + # 邀请码爆破锁定:连续失败达到阈值后按来源锁定一段时间 + blocked, block_for = is_action_blocked("register_invite", identifier=client_ip) + if blocked: + return jsonify({"success": False, "error": tr("auth.too_many_attempts", seconds=block_for), "retry_after": block_for}), 429 + try: state.user_manager.register_user(username, email, password, invite_code) auth_debug_log(f"[auth_debug] POST /register success username={username}") return jsonify({"success": True}) except ValueError as exc: + register_failure("register_invite", state.FAILED_LOGIN_LIMIT, state.FAILED_LOGIN_LOCK_SECONDS, identifier=client_ip) return jsonify({"success": False, "error": str(exc)}), 400 except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 diff --git a/server/chat/files.py b/server/chat/files.py index 11915ba6..33a86736 100644 --- a/server/chat/files.py +++ b/server/chat/files.py @@ -4,8 +4,6 @@ import json, time from datetime import datetime from typing import Dict, Any, Optional from pathlib import Path -from io import BytesIO -import zipfile import os from flask import Blueprint, jsonify, request, session, send_file @@ -252,42 +250,10 @@ def download_file_api(terminal: WebTerminal, workspace: UserWorkspace, username: download_name=full_path.name ) -@chat_bp.route('/api/download/folder') -@api_login_required -@with_terminal -def download_folder_api(terminal: WebTerminal, workspace: UserWorkspace, username: str): - """打包并下载文件夹""" - path = (request.args.get('path') or '').strip() - if not path: - return jsonify({"success": False, "error": tr("chat_files.missing_path_param")}), 400 - - valid, error, full_path = terminal.file_manager._validate_path(path) - if not valid or full_path is None: - return jsonify({"success": False, "error": error or tr("chat_files.path_validation_failed")}), 400 - if not full_path.exists() or not full_path.is_dir(): - return jsonify({"success": False, "error": tr("chat_files.folder_not_found")}), 404 - - buffer = BytesIO() - folder_name = Path(path).name or full_path.name or "archive" - - with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_buffer: - # 确保目录本身被包含 - zip_buffer.write(full_path, arcname=folder_name + '/') - - for item in full_path.rglob('*'): - relative_name = Path(folder_name) / item.relative_to(full_path) - if item.is_dir(): - zip_buffer.write(item, arcname=str(relative_name) + '/') - else: - zip_buffer.write(item, arcname=str(relative_name)) - - buffer.seek(0) - return send_file( - buffer, - mimetype='application/zip', - as_attachment=True, - download_name=f"{folder_name}.zip" - ) +# 文件夹打包下载功能已彻底移除(2026-09-02 安全审计): +# 原 /api/download/folder 端点在打包时不对 rglob 子项做符号链接/越界校验, +# 攻击者在容器工作区内创建指向宿主绝对路径的文件型符号链接即可经 zip 泄露宿主任意文件 +# (含 settings.json / .env / 其他用户数据)。前端已无调用,功能直接下线。 # 文件预览允许 inline 展示的 MIME 前缀白名单 @@ -314,13 +280,16 @@ _FILE_CONTENT_INLINE_TEXT_EXTS = { def _resolve_inline_mime(full_path: Path) -> str: """猜测 inline 预览用的 MIME 类型,不在白名单内的退化为 octet-stream。""" + suffix = full_path.suffix.lower() + # SVG 虽为 image/*,但可携带脚本,inline 渲染即存储型 XSS——与 HTML 同策略强制下载 + if suffix == '.svg': + return 'application/octet-stream' guessed, _ = mimetypes.guess_type(str(full_path)) if guessed: if guessed.startswith(_FILE_CONTENT_INLINE_MIME_PREFIXES): return guessed return 'application/octet-stream' # mimetypes 未识别,按扩展名兑底 - suffix = full_path.suffix.lower() if suffix in _FILE_CONTENT_INLINE_TEXT_EXTS: return 'text/plain; charset=utf-8' if suffix == '.pdf': diff --git a/server/chat/misc.py b/server/chat/misc.py index 1b0e8f67..4b57cbca 100644 --- a/server/chat/misc.py +++ b/server/chat/misc.py @@ -71,7 +71,7 @@ def get_monitor_snapshot_api(): stage = (request.args.get('stage') or 'before').lower() if stage not in {'before', 'after'}: stage = 'before' - snapshot = get_cached_monitor_snapshot(execution_id, stage) + snapshot = get_cached_monitor_snapshot(execution_id, stage, username=get_current_username()) if not snapshot: return jsonify({ 'success': False, diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 03983036..64a3ee54 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -944,7 +944,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages snapshot_path = resolve_monitor_path(arguments) monitor_snapshot = capture_monitor_snapshot(web_terminal.file_manager, snapshot_path, MONITOR_SNAPSHOT_CHAR_LIMIT, debug_log) if monitor_snapshot: - cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot) + cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot, username=username) elif function_name in MONITOR_MEMORY_TOOLS: memory_snapshot_type = (arguments.get('memory_type') or 'main').lower() before_entries = None @@ -957,7 +957,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages 'memory_type': memory_snapshot_type, 'entries': before_entries } - cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot) + cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot, username=username) sender('tool_start', { 'id': tool_display_id, @@ -1300,7 +1300,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages update_payload['awaiting_content'] = True if monitor_snapshot_after: update_payload['monitor_snapshot_after'] = monitor_snapshot_after - cache_monitor_snapshot(tool_display_id, 'after', monitor_snapshot_after) + cache_monitor_snapshot(tool_display_id, 'after', monitor_snapshot_after, username=username) sender('update_action', update_payload) diff --git a/server/context.py b/server/context.py index 8e0cf976..5b417c4d 100644 --- a/server/context.py +++ b/server/context.py @@ -640,6 +640,13 @@ def apply_conversation_overrides(terminal: WebTerminal, workspace, conversation_ meta = data.get("metadata") or {} prompt_name = meta.get("custom_prompt_name") personalization_name = meta.get("personalization_name") + # 安全:元数据中的名称必须过资源名校验,防存储型路径穿越 + import re as _re + def _safe_name(v): + v = (v or "").strip() + return v if _re.fullmatch(r"[A-Za-z0-9_-]{1,64}", v) else None + prompt_name = _safe_name(prompt_name) + personalization_name = _safe_name(personalization_name) # prompt override if prompt_name: prompt_path = Path(workspace.data_dir) / "prompts" / f"{prompt_name}.txt" diff --git a/server/files.py b/server/files.py index 13bfb3d8..33c62527 100644 --- a/server/files.py +++ b/server/files.py @@ -5,8 +5,6 @@ """ from __future__ import annotations import os -import zipfile -from io import BytesIO from pathlib import Path from typing import Dict, Any, List, Optional, Tuple @@ -78,16 +76,9 @@ def gui_download_entry(terminal, workspace, username): try: target = manager.prepare_download(path) if target.is_dir(): - memory_file = BytesIO() - with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf: - for root, dirs, files in os.walk(target): - for file in files: - full_path = Path(root) / file - arcname = manager._to_relative(full_path) - zf.write(full_path, arcname=arcname) - memory_file.seek(0) - download_name = f"{target.name}.zip" - return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip') + # 文件夹打包下载已下线(2026-09-02 安全审计:os.walk/zipfile 对文件型符号链接 + # 会读取链接目标内容,容器内可借此泄露宿主任意文件) + return jsonify({"success": False, "error": tr("files.folder_download_removed")}), 410 return send_file(target, as_attachment=True, download_name=target.name) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 400 diff --git a/server/monitor.py b/server/monitor.py index 7fd491a2..6fd71dd2 100644 --- a/server/monitor.py +++ b/server/monitor.py @@ -7,12 +7,22 @@ from .state import MONITOR_SNAPSHOT_CACHE, MONITOR_SNAPSHOT_CACHE_LIMIT __all__ = ["cache_monitor_snapshot", "get_cached_monitor_snapshot"] -def cache_monitor_snapshot(execution_id: Optional[str], stage: str, snapshot: Optional[Dict[str, Any]]): +def _cache_key(execution_id: Optional[str], username: Optional[str]) -> Optional[str]: + """快照缓存键带用户维度,防止跨用户读取他人工具执行快照。""" + if not execution_id: + return None + return f"{username or ''}:{execution_id}" + + +def cache_monitor_snapshot(execution_id: Optional[str], stage: str, snapshot: Optional[Dict[str, Any]], username: Optional[str] = None): """缓存工具执行前/后的文件快照。""" if not execution_id or not snapshot or not snapshot.get('content'): return + cache_key = _cache_key(execution_id, username) + if not cache_key: + return normalized_stage = 'after' if stage == 'after' else 'before' - entry = MONITOR_SNAPSHOT_CACHE.get(execution_id) or { + entry = MONITOR_SNAPSHOT_CACHE.get(cache_key) or { 'before': None, 'after': None, 'path': snapshot.get('path'), @@ -25,7 +35,7 @@ def cache_monitor_snapshot(execution_id: Optional[str], stage: str, snapshot: Op } entry['path'] = snapshot.get('path') or entry.get('path') entry['timestamp'] = time.time() - MONITOR_SNAPSHOT_CACHE[execution_id] = entry + MONITOR_SNAPSHOT_CACHE[cache_key] = entry if len(MONITOR_SNAPSHOT_CACHE) > MONITOR_SNAPSHOT_CACHE_LIMIT: try: oldest_key = min( @@ -37,10 +47,11 @@ def cache_monitor_snapshot(execution_id: Optional[str], stage: str, snapshot: Op pass -def get_cached_monitor_snapshot(execution_id: Optional[str], stage: str) -> Optional[Dict[str, Any]]: - if not execution_id: +def get_cached_monitor_snapshot(execution_id: Optional[str], stage: str, username: Optional[str] = None) -> Optional[Dict[str, Any]]: + cache_key = _cache_key(execution_id, username) + if not cache_key: return None - entry = MONITOR_SNAPSHOT_CACHE.get(execution_id) + entry = MONITOR_SNAPSHOT_CACHE.get(cache_key) if not entry: return None normalized_stage = 'after' if stage == 'after' else 'before' diff --git a/server/security.py b/server/security.py index 00d6439b..3da4c18d 100644 --- a/server/security.py +++ b/server/security.py @@ -1,6 +1,7 @@ """安全相关工具:限流、CSRF、Socket Token、工具结果压缩等。""" from __future__ import annotations import hmac +import os import secrets import time from typing import Dict, Any, Optional, Tuple @@ -9,13 +10,34 @@ from functools import wraps from . import state + +def _trusted_proxies() -> frozenset: + """可信反向代理地址集合(仅这些来源的 X-Forwarded-For 才被采信)。 + + 默认不信任任何代理:服务直连时 XFF 可被客户端任意伪造,限流/锁定全部失效。 + 部署在 nginx/CF 等反代之后时,用环境变量 ASTRION_TRUSTED_PROXIES 配置, + 逗号分隔,如 "127.0.0.1,::1"。 + """ + raw = os.environ.get("ASTRION_TRUSTED_PROXIES", "") + return frozenset(x.strip() for x in raw.split(",") if x.strip()) + + +_TRUSTED_PROXIES = _trusted_proxies() + +# 内存限流桶/失败计数表的硬性上限,防止伪造来源导致内存无限增长(反向 DoS)。 +_RATE_LIMIT_MAX_KEYS = 20000 +_FAILURE_TRACKER_MAX_KEYS = 20000 + + # 便捷别名 def get_client_ip() -> str: - """获取客户端IP,支持 X-Forwarded-For.""" - forwarded = request.headers.get("X-Forwarded-For") - if forwarded: - return forwarded.split(",")[0].strip() - return request.remote_addr or "unknown" + """获取客户端IP。仅当直接对端是可信代理时才采信 X-Forwarded-For。""" + remote = request.remote_addr or "unknown" + if remote in _TRUSTED_PROXIES: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() or remote + return remote def resolve_identifier(scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> str: @@ -44,9 +66,38 @@ def check_rate_limit(action: str, limit: int, window_seconds: int, identifier: O retry_after = window_seconds - int(now - bucket[0]) return True, max(retry_after, 1) bucket.append(now) + _gc_rate_limit_buckets(now) return False, 0 +def _gc_rate_limit_buckets(now: float) -> None: + """限流桶回收:超过硬上限时清掉已空置/全部过期的桶,防伪造来源刷爆内存。""" + buckets = state.RATE_LIMIT_BUCKETS + if len(buckets) <= _RATE_LIMIT_MAX_KEYS: + return + for key in list(buckets.keys()): + bucket = buckets.get(key) + if not bucket: + buckets.pop(key, None) + if len(buckets) > _RATE_LIMIT_MAX_KEYS: + # 极端情况下(全部桶都活跃)直接整体清空,宁可误伤正常限流状态也不撑爆内存 + buckets.clear() + + +def gc_failure_trackers() -> None: + """失败计数表回收:超过硬上限时清掉已解除锁定的条目。""" + trackers = state.FAILURE_TRACKERS + if len(trackers) <= _FAILURE_TRACKER_MAX_KEYS: + return + now = time.time() + for key in list(trackers.keys()): + entry = trackers.get(key) or {} + if not entry.get("blocked_until") or entry["blocked_until"] <= now: + trackers.pop(key, None) + if len(trackers) > _FAILURE_TRACKER_MAX_KEYS: + trackers.clear() + + def rate_limited(action: str, limit: int, window_seconds: int, scope: str = "ip", error_message: Optional[str] = None): """装饰器:为路由增加速率限制。""" def decorator(func): @@ -68,6 +119,7 @@ def rate_limited(action: str, limit: int, window_seconds: int, scope: str = "ip" def register_failure(action: str, limit: int, lock_seconds: int, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> int: """记录失败次数,超过阈值后触发锁定。""" + gc_failure_trackers() ident = resolve_identifier(scope, identifier, kwargs) key = f"{action}:{ident}" now = time.time() @@ -204,6 +256,18 @@ def compact_web_search_result(result_data: Dict[str, Any]) -> Dict[str, Any]: def attach_security_hooks(app): """注册 CSRF 校验与通用安全响应头。""" + @app.before_request + def _block_admin_static_for_non_admin(): + # Flask 内置 static 路由会优先于蓝图里的 /static/ 拦截, + # 因此 admin_dashboard 静态资源必须在 before_request 层面拦截。 + path = request.path or "" + if path.startswith("/static/admin_dashboard"): + from .auth_helpers import get_current_user_record # 局部导入避免循环 + user = get_current_user_record() + if not user or getattr(user, "role", None) != "admin": + from flask import abort + abort(404) + @app.before_request def _enforce_csrf_token(): method = (request.method or "GET").upper() diff --git a/server/status/app.py b/server/status/app.py index 3f6e4908..34392735 100644 --- a/server/status/app.py +++ b/server/status/app.py @@ -74,6 +74,15 @@ def get_app_version_info(): @status_bp.route('/api/app/apk/latest') def download_latest_apk(): + # 安全:未认证全量下载 130MB+ 文件是带宽放大攻击面(2026-09-02 审计); + # 与 /api/app/version 一致要求登录,并加来源限流。 + from server.auth_helpers import is_logged_in + if not is_logged_in(): + return jsonify({"success": False, "error": tr("auth.not_logged_in")}), 401 + from server.security import check_rate_limit, get_client_ip + limited, retry_after = check_rate_limit("apk_download", 10, 60, get_client_ip()) + if limited: + return jsonify({"success": False, "error": "rate_limited", "retry_after": retry_after}), 429 project_root = Path(__file__).resolve().parent.parent.parent apk_path = _resolve_android_apk_path(project_root) if not apk_path.exists(): diff --git a/server/tasks/api.py b/server/tasks/api.py index 21251aec..cf042d1a 100644 --- a/server/tasks/api.py +++ b/server/tasks/api.py @@ -114,6 +114,9 @@ def create_task_api(): workspace_id = session.get("workspace_id") or "default" payload = request.get_json() or {} message = (payload.get("message") or "").strip() + from config import MAX_MESSAGE_CHARS + if len(message) > MAX_MESSAGE_CHARS: + return jsonify({"success": False, "error": tr("tasks.message_too_long")}), 400 images, videos = _normalize_media_payload(payload.get("images") or [], payload.get("videos") or []) files = _normalize_files_payload(payload.get("files")) conversation_id = payload.get("conversation_id")