fix(security): Docker 多用户模式全面安全加固(安全审计修复)

威胁模型:拥有普通账号的已登录用户攻击服务器。完整审计报告见
_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)。
This commit is contained in:
JOJO 2026-09-02 18:31:00 +08:00
parent 39a7a5fc6f
commit 596e781555
26 changed files with 326 additions and 96 deletions

View File

@ -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=禁用

View File

@ -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

View File

@ -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防止单用户资源耗尽型 DoS2026-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",

View File

@ -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

View File

@ -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():

View File

@ -209,7 +209,15 @@ class CrudMixin:
valid, error, full_path = self._validate_path(path)
if not valid:
return {"success": False, "error": error}
# 安全:禁止删除工作区根目录本身(空路径/点路径会解析为根;
# docker 模式下该目录是宿主 bind mountrmtree 会真实删除宿主文件)
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")}

View File

@ -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():

View File

@ -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",

View File

@ -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",
},
}

View File

@ -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",

View File

@ -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}",

View File

@ -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})",

View File

@ -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",

View File

@ -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:

View File

@ -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/<workspace_id>/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/<workspace_id>/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/<workspace_id>/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):

View File

@ -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)

View File

@ -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

View File

@ -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':

View File

@ -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,

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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'

View File

@ -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/<path> 拦截,
# 因此 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()

View File

@ -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():

View File

@ -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")