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:
parent
39a7a5fc6f
commit
596e781555
12
.env.example
12
.env.example
@ -44,6 +44,10 @@ WEB_SERVER_PORT=8091
|
|||||||
WEB_SERVER_HOST=0.0.0.0
|
WEB_SERVER_HOST=0.0.0.0
|
||||||
# 调试模式(同时控制 Flask reloader),生产保持 0
|
# 调试模式(同时控制 Flask reloader),生产保持 0
|
||||||
WEB_SERVER_DEBUG=0
|
WEB_SERVER_DEBUG=0
|
||||||
|
# 可信反向代理列表(逗号分隔 IP,默认空=不信任任何 X-Forwarded-For)
|
||||||
|
# 仅当部署在 nginx/CF 等反代之后时才需要配置,例如 ASTRION_TRUSTED_PROXIES=127.0.0.1
|
||||||
|
# 直连部署切勿配置,否则登录限流可被伪造的 XFF 绕过
|
||||||
|
# ASTRION_TRUSTED_PROXIES=
|
||||||
|
|
||||||
# === 运行环境二进制(便携 release 包用,开发环境留默认即可)==================
|
# === 运行环境二进制(便携 release 包用,开发环境留默认即可)==================
|
||||||
# 子智能体使用的 node 可执行文件;留 "node" 表示走系统 PATH
|
# 子智能体使用的 node 可执行文件;留 "node" 表示走系统 PATH
|
||||||
@ -64,7 +68,11 @@ TERMINAL_SANDBOX_SHELL=/bin/bash
|
|||||||
TERMINAL_SANDBOX_NETWORK=bridge
|
TERMINAL_SANDBOX_NETWORK=bridge
|
||||||
TERMINAL_SANDBOX_CPUS=0.5
|
TERMINAL_SANDBOX_CPUS=0.5
|
||||||
TERMINAL_SANDBOX_MEMORY=1g
|
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_BINDS=
|
||||||
# 运行时路径及容器名称前缀
|
# 运行时路径及容器名称前缀
|
||||||
TERMINAL_SANDBOX_BIN=docker
|
TERMINAL_SANDBOX_BIN=docker
|
||||||
@ -81,6 +89,10 @@ HOST_SANDBOX_MACOS_WRITABLE_PATHS=
|
|||||||
# === 资源控制 ================================================================
|
# === 资源控制 ================================================================
|
||||||
PROJECT_MAX_STORAGE_MB=2048
|
PROJECT_MAX_STORAGE_MB=2048
|
||||||
MAX_ACTIVE_USER_CONTAINERS=8
|
MAX_ACTIVE_USER_CONTAINERS=8
|
||||||
|
# 单个用户最多同时保活的容器数(防单用户占满全局容器池)
|
||||||
|
MAX_ACTIVE_CONTAINERS_PER_USER=3
|
||||||
|
# 单条消息最大字符数(防超大消息撑爆模型上下文/存储)
|
||||||
|
MAX_MESSAGE_CHARS=200000
|
||||||
|
|
||||||
# === MCP 工具扩展(可选) =====================================================
|
# === MCP 工具扩展(可选) =====================================================
|
||||||
# 1=启用,0=禁用
|
# 1=启用,0=禁用
|
||||||
|
|||||||
@ -13,6 +13,9 @@ CODE_EXECUTION_TIMEOUT = 60
|
|||||||
TERMINAL_COMMAND_TIMEOUT = 120
|
TERMINAL_COMMAND_TIMEOUT = 120
|
||||||
SEARCH_MAX_RESULTS = 10
|
SEARCH_MAX_RESULTS = 10
|
||||||
|
|
||||||
|
# 单条用户消息最大字符数(防成本攻击与内存放大;2026-09-02 安全审计新增)
|
||||||
|
MAX_MESSAGE_CHARS = int(os.environ.get("MAX_MESSAGE_CHARS", "200000") or 200000)
|
||||||
|
|
||||||
# 自动修复与工具调用限制(None 表示不限制)
|
# 自动修复与工具调用限制(None 表示不限制)
|
||||||
AUTO_FIX_TOOL_CALL = False
|
AUTO_FIX_TOOL_CALL = False
|
||||||
AUTO_FIX_MAX_ATTEMPTS = 3
|
AUTO_FIX_MAX_ATTEMPTS = 3
|
||||||
|
|||||||
@ -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_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace")
|
||||||
TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash")
|
TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash")
|
||||||
TERMINAL_SANDBOX_NETWORK = os.environ.get("TERMINAL_SANDBOX_NETWORK", "bridge")
|
TERMINAL_SANDBOX_NETWORK = os.environ.get("TERMINAL_SANDBOX_NETWORK", "bridge")
|
||||||
TERMINAL_SANDBOX_CPUS = os.environ.get("TERMINAL_SANDBOX_CPUS", "")
|
# 安全默认:限制单容器 CPU/内存/PID,防止单用户资源耗尽型 DoS(2026-09-02 审计)。
|
||||||
TERMINAL_SANDBOX_MEMORY = os.environ.get("TERMINAL_SANDBOX_MEMORY", "")
|
# 如需放开可显式设为空字符串(不推荐多用户部署放开)。
|
||||||
|
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_BINDS = _parse_bindings(os.environ.get("TERMINAL_SANDBOX_BINDS", ""))
|
||||||
TERMINAL_SANDBOX_BIN = os.environ.get("TERMINAL_SANDBOX_BIN", "docker")
|
TERMINAL_SANDBOX_BIN = os.environ.get("TERMINAL_SANDBOX_BIN", "docker")
|
||||||
TERMINAL_SANDBOX_NAME_PREFIX = os.environ.get("TERMINAL_SANDBOX_NAME_PREFIX", "agent-term")
|
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"}
|
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"))
|
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"))
|
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()
|
HOST_EXECUTION_MODE_DEFAULT = os.environ.get("HOST_EXECUTION_MODE_DEFAULT", "sandbox").strip().lower()
|
||||||
# 沙箱可写路径的「部署通道」(逗号分隔)。路径授权只有两个来源:
|
# 沙箱可写路径的「部署通道」(逗号分隔)。路径授权只有两个来源:
|
||||||
# config/host_sandbox_policy.json(前端「路径授权」UI)+ 本变量(真·环境变量),
|
# config/host_sandbox_policy.json(前端「路径授权」UI)+ 本变量(真·环境变量),
|
||||||
@ -83,6 +88,7 @@ __all__ = [
|
|||||||
"TERMINAL_SANDBOX_NETWORK",
|
"TERMINAL_SANDBOX_NETWORK",
|
||||||
"TERMINAL_SANDBOX_CPUS",
|
"TERMINAL_SANDBOX_CPUS",
|
||||||
"TERMINAL_SANDBOX_MEMORY",
|
"TERMINAL_SANDBOX_MEMORY",
|
||||||
|
"TERMINAL_SANDBOX_PIDS_LIMIT",
|
||||||
"TERMINAL_SANDBOX_BINDS",
|
"TERMINAL_SANDBOX_BINDS",
|
||||||
"TERMINAL_SANDBOX_BIN",
|
"TERMINAL_SANDBOX_BIN",
|
||||||
"TERMINAL_SANDBOX_NAME_PREFIX",
|
"TERMINAL_SANDBOX_NAME_PREFIX",
|
||||||
@ -91,6 +97,7 @@ __all__ = [
|
|||||||
"LINUX_SAFETY",
|
"LINUX_SAFETY",
|
||||||
"TOOLBOX_TERMINAL_IDLE_SECONDS",
|
"TOOLBOX_TERMINAL_IDLE_SECONDS",
|
||||||
"MAX_ACTIVE_USER_CONTAINERS",
|
"MAX_ACTIVE_USER_CONTAINERS",
|
||||||
|
"MAX_ACTIVE_CONTAINERS_PER_USER",
|
||||||
"HOST_EXECUTION_MODE_DEFAULT",
|
"HOST_EXECUTION_MODE_DEFAULT",
|
||||||
"HOST_SANDBOX_MACOS_WRITABLE_PATHS",
|
"HOST_SANDBOX_MACOS_WRITABLE_PATHS",
|
||||||
"HOST_SANDBOX_NETWORK_PERMISSION",
|
"HOST_SANDBOX_NETWORK_PERMISSION",
|
||||||
|
|||||||
@ -115,6 +115,10 @@ class ApiUserManager:
|
|||||||
ws_id = (workspace_id or "default").strip()
|
ws_id = (workspace_id or "default").strip()
|
||||||
if not ws_id:
|
if not ws_id:
|
||||||
ws_id = "default"
|
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()
|
user_root = (self.workspace_root / username).resolve()
|
||||||
shared_dir = user_root / "shared"
|
shared_dir = user_root / "shared"
|
||||||
@ -284,7 +288,15 @@ class ApiUserManager:
|
|||||||
ws_id = (workspace_id or "").strip()
|
ws_id = (workspace_id or "").strip()
|
||||||
if not ws_id:
|
if not ws_id:
|
||||||
return False
|
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()
|
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():
|
if not work_root.exists():
|
||||||
return False
|
return False
|
||||||
import shutil
|
import shutil
|
||||||
|
|||||||
@ -19,7 +19,8 @@ import shutil
|
|||||||
def _resolve(root: pathlib.Path, rel: str) -> pathlib.Path:
|
def _resolve(root: pathlib.Path, rel: str) -> pathlib.Path:
|
||||||
base = root.resolve()
|
base = root.resolve()
|
||||||
target = (base / rel).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)
|
raise ValueError("路径越界: %s" % rel)
|
||||||
return target
|
return target
|
||||||
|
|
||||||
@ -82,7 +83,12 @@ def _create_folder(root, payload):
|
|||||||
|
|
||||||
def _delete_folder(root, payload):
|
def _delete_folder(root, payload):
|
||||||
rel = payload.get("path")
|
rel = payload.get("path")
|
||||||
|
# 安全:禁止删除工作区根本身(防 rmtree 抹掉整个 bind-mount 工作区)
|
||||||
|
if not rel or str(rel).strip() in {"", ".", "./", ".."}:
|
||||||
|
return {"success": False, "error": "不允许删除工作区根目录"}
|
||||||
target = _resolve(root, rel)
|
target = _resolve(root, rel)
|
||||||
|
if target == root.resolve():
|
||||||
|
return {"success": False, "error": "不允许删除工作区根目录"}
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
return {"success": False, "error": "文件夹不存在"}
|
return {"success": False, "error": "文件夹不存在"}
|
||||||
if not target.is_dir():
|
if not target.is_dir():
|
||||||
|
|||||||
@ -209,7 +209,15 @@ class CrudMixin:
|
|||||||
valid, error, full_path = self._validate_path(path)
|
valid, error, full_path = self._validate_path(path)
|
||||||
if not valid:
|
if not valid:
|
||||||
return {"success": False, "error": error}
|
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():
|
if not full_path.exists():
|
||||||
return {"success": False, "error": tr("file_manager.folder_not_found")}
|
return {"success": False, "error": tr("file_manager.folder_not_found")}
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
def create_entry(self, parent_relative: Optional[str], name: str, entry_type: str) -> str:
|
||||||
parent = self._resolve(parent_relative)
|
parent = self._resolve(parent_relative)
|
||||||
if not parent.exists():
|
if not parent.exists():
|
||||||
@ -174,9 +183,7 @@ class GuiFileManager:
|
|||||||
if not parent.is_dir():
|
if not parent.is_dir():
|
||||||
raise NotADirectoryError(tr("gui_file.parent_not_directory"))
|
raise NotADirectoryError(tr("gui_file.parent_not_directory"))
|
||||||
|
|
||||||
sanitized = name.strip()
|
sanitized = self._sanitize_entry_name(name)
|
||||||
if not sanitized:
|
|
||||||
raise ValueError(tr("gui_file.name_empty"))
|
|
||||||
|
|
||||||
target = parent / sanitized
|
target = parent / sanitized
|
||||||
if target.exists():
|
if target.exists():
|
||||||
@ -215,8 +222,9 @@ class GuiFileManager:
|
|||||||
raise FileNotFoundError(tr("gui_file.target_not_found"))
|
raise FileNotFoundError(tr("gui_file.target_not_found"))
|
||||||
|
|
||||||
parent = target.parent
|
parent = target.parent
|
||||||
sanitized = new_name.strip()
|
try:
|
||||||
if not sanitized:
|
sanitized = self._sanitize_entry_name(new_name)
|
||||||
|
except ValueError:
|
||||||
raise ValueError(tr("gui_file.new_name_empty"))
|
raise ValueError(tr("gui_file.new_name_empty"))
|
||||||
new_path = parent / sanitized
|
new_path = parent / sanitized
|
||||||
if new_path.exists():
|
if new_path.exists():
|
||||||
|
|||||||
@ -16,6 +16,10 @@ MESSAGES = {
|
|||||||
"zh-CN": "消息不能为空",
|
"zh-CN": "消息不能为空",
|
||||||
"en-US": "Message cannot be empty",
|
"en-US": "Message cannot be empty",
|
||||||
},
|
},
|
||||||
|
"tasks.message_too_long": {
|
||||||
|
"zh-CN": "消息内容过长",
|
||||||
|
"en-US": "Message is too long",
|
||||||
|
},
|
||||||
"tasks.workspace_unavailable": {
|
"tasks.workspace_unavailable": {
|
||||||
"zh-CN": "工作区不可用",
|
"zh-CN": "工作区不可用",
|
||||||
"en-US": "Workspace is unavailable",
|
"en-US": "Workspace is unavailable",
|
||||||
|
|||||||
@ -111,4 +111,16 @@ MESSAGES = {
|
|||||||
"zh-CN": "保存失败: {error}",
|
"zh-CN": "保存失败: {error}",
|
||||||
"en-US": "Failed to save: {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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@ -154,6 +154,10 @@ MESSAGES = {
|
|||||||
"zh-CN": "文件夹不存在",
|
"zh-CN": "文件夹不存在",
|
||||||
"en-US": "Folder not found",
|
"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": {
|
"file_manager.not_a_folder": {
|
||||||
"zh-CN": "不是文件夹",
|
"zh-CN": "不是文件夹",
|
||||||
"en-US": "Not a folder",
|
"en-US": "Not a folder",
|
||||||
|
|||||||
@ -219,6 +219,10 @@ MESSAGES = {
|
|||||||
"zh-CN": "资源繁忙:容器配额已用尽,请稍候再试。",
|
"zh-CN": "资源繁忙:容器配额已用尽,请稍候再试。",
|
||||||
"en-US": "Busy: container quota exhausted, please try again later",
|
"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": {
|
"container_mgr.runtime_not_found": {
|
||||||
"zh-CN": "未找到容器运行时 {runtime}",
|
"zh-CN": "未找到容器运行时 {runtime}",
|
||||||
"en-US": "Container runtime not found: {runtime}",
|
"en-US": "Container runtime not found: {runtime}",
|
||||||
|
|||||||
@ -140,6 +140,10 @@ MESSAGES = {
|
|||||||
"zh-CN": "用户名不能为空",
|
"zh-CN": "用户名不能为空",
|
||||||
"en-US": "Username cannot be empty",
|
"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": {
|
"api_user_mgr.users_file_parse_failed": {
|
||||||
"zh-CN": "无法解析 API 用户文件: {file_path} ({error})",
|
"zh-CN": "无法解析 API 用户文件: {file_path} ({error})",
|
||||||
"en-US": "Failed to parse API user file: {file_path} ({error})",
|
"en-US": "Failed to parse API user file: {file_path} ({error})",
|
||||||
|
|||||||
@ -34,6 +34,10 @@ MESSAGES = {
|
|||||||
"zh-CN": "缺少 path",
|
"zh-CN": "缺少 path",
|
||||||
"en-US": "Missing path",
|
"en-US": "Missing path",
|
||||||
},
|
},
|
||||||
|
"files.folder_download_removed": {
|
||||||
|
"zh-CN": "文件夹打包下载功能已下线",
|
||||||
|
"en-US": "Folder archive download has been removed",
|
||||||
|
},
|
||||||
"files.missing_path_or_content": {
|
"files.missing_path_or_content": {
|
||||||
"zh-CN": "缺少 path 或 content",
|
"zh-CN": "缺少 path 或 content",
|
||||||
"en-US": "Missing path or content",
|
"en-US": "Missing path or content",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
|
MAX_ACTIVE_CONTAINERS_PER_USER,
|
||||||
MAX_ACTIVE_USER_CONTAINERS,
|
MAX_ACTIVE_USER_CONTAINERS,
|
||||||
OUTPUT_FORMATS,
|
OUTPUT_FORMATS,
|
||||||
TERMINAL_SANDBOX_BIN,
|
TERMINAL_SANDBOX_BIN,
|
||||||
@ -27,6 +28,7 @@ from config import (
|
|||||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||||
TERMINAL_SANDBOX_NAME_PREFIX,
|
TERMINAL_SANDBOX_NAME_PREFIX,
|
||||||
TERMINAL_SANDBOX_NETWORK,
|
TERMINAL_SANDBOX_NETWORK,
|
||||||
|
TERMINAL_SANDBOX_PIDS_LIMIT,
|
||||||
TERMINAL_SANDBOX_REQUIRE,
|
TERMINAL_SANDBOX_REQUIRE,
|
||||||
LOGS_DIR,
|
LOGS_DIR,
|
||||||
LINUX_SAFETY,
|
LINUX_SAFETY,
|
||||||
@ -80,6 +82,7 @@ class UserContainerManager:
|
|||||||
self.network = TERMINAL_SANDBOX_NETWORK
|
self.network = TERMINAL_SANDBOX_NETWORK
|
||||||
self.cpus = TERMINAL_SANDBOX_CPUS
|
self.cpus = TERMINAL_SANDBOX_CPUS
|
||||||
self.memory = TERMINAL_SANDBOX_MEMORY
|
self.memory = TERMINAL_SANDBOX_MEMORY
|
||||||
|
self.pids_limit = TERMINAL_SANDBOX_PIDS_LIMIT
|
||||||
self.binds = list(TERMINAL_SANDBOX_BINDS)
|
self.binds = list(TERMINAL_SANDBOX_BINDS)
|
||||||
self.sandbox_bin = TERMINAL_SANDBOX_BIN or "docker"
|
self.sandbox_bin = TERMINAL_SANDBOX_BIN or "docker"
|
||||||
self.name_prefix = TERMINAL_SANDBOX_NAME_PREFIX or "agent-user"
|
self.name_prefix = TERMINAL_SANDBOX_NAME_PREFIX or "agent-user"
|
||||||
@ -158,6 +161,16 @@ class UserContainerManager:
|
|||||||
if not self._has_capacity(key):
|
if not self._has_capacity(key):
|
||||||
raise RuntimeError(tr("container_mgr.quota_exhausted"))
|
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.
|
# Important: create container using the cache key so each workspace gets its own container name.
|
||||||
handle = self._create_handle(key, workspace, mode)
|
handle = self._create_handle(key, workspace, mode)
|
||||||
self._containers[key] = handle
|
self._containers[key] = handle
|
||||||
@ -327,6 +340,12 @@ class UserContainerManager:
|
|||||||
cmd += ["--cpus", str(self.cpus)]
|
cmd += ["--cpus", str(self.cpus)]
|
||||||
if self.memory:
|
if self.memory:
|
||||||
cmd += ["--memory", str(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:
|
for bind in self.binds:
|
||||||
chunk = bind.strip()
|
chunk = bind.strip()
|
||||||
if chunk:
|
if chunk:
|
||||||
|
|||||||
@ -2,14 +2,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import zipfile
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, send_file, session
|
from flask import Blueprint, request, jsonify, send_file, session
|
||||||
|
|
||||||
from .api_auth import api_token_required
|
from .api_auth import api_token_required
|
||||||
|
from .security import rate_limited
|
||||||
from .tasks import task_manager
|
from .tasks import task_manager
|
||||||
from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides
|
from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides
|
||||||
from .utils_common import sanitize_filename_preserve_unicode
|
from .utils_common import sanitize_filename_preserve_unicode
|
||||||
@ -69,6 +68,7 @@ def list_workspaces_api():
|
|||||||
|
|
||||||
@api_v1_bp.route("/workspaces", methods=["POST"])
|
@api_v1_bp.route("/workspaces", methods=["POST"])
|
||||||
@api_token_required
|
@api_token_required
|
||||||
|
@rate_limited("api_v1_create_ws", 10, 300, scope="user")
|
||||||
def create_workspace_api():
|
def create_workspace_api():
|
||||||
username = session.get("username")
|
username = session.get("username")
|
||||||
payload = request.get_json(silent=True) or {}
|
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})
|
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:
|
def _within_uploads(workspace, rel_path: str) -> Path:
|
||||||
base = Path(workspace.uploads_dir).resolve()
|
base = Path(workspace.uploads_dir).resolve()
|
||||||
rel = rel_path or ""
|
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.split("user_upload/", 1)[1]
|
||||||
rel = rel.lstrip("/").strip()
|
rel = rel.lstrip("/").strip()
|
||||||
target = (base / rel).resolve()
|
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"))
|
raise ValueError(tr("api_v1_raise.invalid_path"))
|
||||||
return target
|
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.split("/workspace/", 1)[1]
|
||||||
rel = rel.lstrip("/")
|
rel = rel.lstrip("/")
|
||||||
target = (base / rel).resolve()
|
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"))
|
raise ValueError(tr("api_v1_raise.invalid_path"))
|
||||||
return target
|
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_v1_bp.route("/workspaces/<workspace_id>/conversations", methods=["POST"])
|
||||||
@api_token_required
|
@api_token_required
|
||||||
|
@rate_limited("api_v1_create_conv", 30, 60, scope="user")
|
||||||
def create_conversation_api(workspace_id: str):
|
def create_conversation_api(workspace_id: str):
|
||||||
username = session.get("username")
|
username = session.get("username")
|
||||||
ws = _resolve_workspace(username, workspace_id)
|
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_v1_bp.route("/workspaces/<workspace_id>/messages", methods=["POST"])
|
||||||
@api_token_required
|
@api_token_required
|
||||||
|
@rate_limited("api_v1_send_msg", 20, 60, scope="user")
|
||||||
def send_message_api(workspace_id: str):
|
def send_message_api(workspace_id: str):
|
||||||
username = session.get("username")
|
username = session.get("username")
|
||||||
ws = _resolve_workspace(username, workspace_id)
|
ws = _resolve_workspace(username, workspace_id)
|
||||||
payload = request.get_json() or {}
|
payload = request.get_json() or {}
|
||||||
message = (payload.get("message") or "").strip()
|
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 []
|
images = payload.get("images") or []
|
||||||
conversation_id = payload.get("conversation_id")
|
conversation_id = payload.get("conversation_id")
|
||||||
model_key = payload.get("model_key")
|
model_key = payload.get("model_key")
|
||||||
@ -260,11 +284,13 @@ def send_message_api(workspace_id: str):
|
|||||||
# 应用自定义 prompt/personalization(如果提供)
|
# 应用自定义 prompt/personalization(如果提供)
|
||||||
try:
|
try:
|
||||||
if prompt_name:
|
if prompt_name:
|
||||||
|
prompt_name = _validate_resource_name(prompt_name)
|
||||||
prompt_path = _prompt_dir(workspace) / f"{prompt_name}.txt"
|
prompt_path = _prompt_dir(workspace) / f"{prompt_name}.txt"
|
||||||
if not prompt_path.exists():
|
if not prompt_path.exists():
|
||||||
return jsonify({"success": False, "error": tr("api_v1.prompt_not_found")}), 404
|
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")
|
terminal.context_manager.custom_system_prompt = prompt_path.read_text(encoding="utf-8")
|
||||||
if personalization_name:
|
if personalization_name:
|
||||||
|
personalization_name = _validate_resource_name(personalization_name)
|
||||||
pers_path = _personalization_dir(workspace) / f"{personalization_name}.json"
|
pers_path = _personalization_dir(workspace) / f"{personalization_name}.json"
|
||||||
if not pers_path.exists():
|
if not pers_path.exists():
|
||||||
return jsonify({"success": False, "error": tr("api_v1.personalization_not_found")}), 404
|
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_v1_bp.route("/workspaces/<workspace_id>/files/upload", methods=["POST"])
|
||||||
@api_token_required
|
@api_token_required
|
||||||
|
@rate_limited("api_v1_upload", 20, 300, scope="user")
|
||||||
def upload_file_api(workspace_id: str):
|
def upload_file_api(workspace_id: str):
|
||||||
username = session.get("username")
|
username = session.get("username")
|
||||||
ws = _resolve_workspace(username, workspace_id)
|
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
|
return jsonify({"success": False, "error": tr("api_v1.file_does_not_exist")}), 404
|
||||||
|
|
||||||
if target.is_dir():
|
if target.is_dir():
|
||||||
memory_file = BytesIO()
|
# 文件夹打包下载已下线(2026-09-02 安全审计:os.walk/zipfile 跟随文件型
|
||||||
with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
|
# 符号链接,可泄露宿主任意文件)
|
||||||
for root, _, files in os.walk(target):
|
return jsonify({"success": False, "error": tr("api_v1.folder_download_removed")}), 410
|
||||||
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')
|
|
||||||
return send_file(target, as_attachment=True, download_name=target.name)
|
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)
|
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
|
||||||
if not workspace:
|
if not workspace:
|
||||||
return jsonify({"success": False, "error": tr("api_v1.system_not_initialized")}), 503
|
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"
|
p = _prompt_dir(workspace) / f"{name}.txt"
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
return jsonify({"success": False, "error": tr("api_v1.prompt_not_found")}), 404
|
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 ""
|
content = payload.get("content") or ""
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({"success": False, "error": tr("api_v1.name_empty")}), 400
|
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 = _prompt_dir(workspace) / f"{name}.txt"
|
||||||
p.write_text(content, encoding="utf-8")
|
p.write_text(content, encoding="utf-8")
|
||||||
return jsonify({"success": True, "name": name})
|
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)
|
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
|
||||||
if not workspace:
|
if not workspace:
|
||||||
return jsonify({"success": False, "error": tr("api_v1.system_not_initialized")}), 503
|
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"
|
p = _personalization_dir(workspace) / f"{name}.json"
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
return jsonify({"success": False, "error": tr("api_v1.personalization_not_found")}), 404
|
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
|
return jsonify({"success": False, "error": tr("api_v1.name_empty")}), 400
|
||||||
if content is None:
|
if content is None:
|
||||||
return jsonify({"success": False, "error": tr("api_v1.content_empty")}), 400
|
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"
|
p = _personalization_dir(workspace) / f"{name}.json"
|
||||||
try:
|
try:
|
||||||
if not isinstance(content, dict):
|
if not isinstance(content, dict):
|
||||||
|
|||||||
@ -1142,9 +1142,16 @@ def resource_busy_page():
|
|||||||
|
|
||||||
@app.route('/api/client_debug_log', methods=['POST'])
|
@app.route('/api/client_debug_log', methods=['POST'])
|
||||||
def client_debug_log():
|
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:
|
try:
|
||||||
data = request.get_json(silent=True) or {}
|
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 = dict(data)
|
||||||
entry.setdefault('server_ts', time.time())
|
entry.setdefault('server_ts', time.time())
|
||||||
log_goal_mode_debug_entry(entry)
|
log_goal_mode_debug_entry(entry)
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from .security import (
|
|||||||
register_failure,
|
register_failure,
|
||||||
is_action_blocked,
|
is_action_blocked,
|
||||||
clear_failures,
|
clear_failures,
|
||||||
|
get_client_ip,
|
||||||
)
|
)
|
||||||
from . import state
|
from . import state
|
||||||
from .utils_common import debug_log
|
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")
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
line = f"[{ts}] {message}"
|
line = f"[{ts}] {message}"
|
||||||
try:
|
try:
|
||||||
with AUTH_DEBUG_FILE.open("a", encoding="utf-8") as f:
|
# 走统一轮转,防止未认证请求刷爆磁盘(此前为无上限 append)
|
||||||
f.write(line + "\n")
|
from utils.log_rotation import append_line
|
||||||
|
append_line(AUTH_DEBUG_FILE, line)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
@ -139,7 +141,9 @@ def login():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
email = (data.get('email') or '').strip()
|
email = (data.get('email') or '').strip()
|
||||||
password = data.get('password') or ''
|
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)
|
limited, retry_after = check_rate_limit("login", 10, 60, client_ip)
|
||||||
if limited:
|
if limited:
|
||||||
@ -149,8 +153,14 @@ def login():
|
|||||||
if blocked:
|
if blocked:
|
||||||
return jsonify({"success": False, "error": tr("auth.too_many_attempts", seconds=block_for), "retry_after": block_for}), 429
|
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)
|
record = state.user_manager.authenticate(email, password)
|
||||||
if not record:
|
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)
|
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")}
|
error_payload = {"success": False, "error": tr("auth.invalid_credentials")}
|
||||||
status_code = 401
|
status_code = 401
|
||||||
@ -193,6 +203,7 @@ def login():
|
|||||||
session.permanent = True
|
session.permanent = True
|
||||||
_issue_login_nonce(record.username)
|
_issue_login_nonce(record.username)
|
||||||
clear_failures("login", identifier=client_ip)
|
clear_failures("login", identifier=client_ip)
|
||||||
|
clear_failures("login_account", identifier=account_key)
|
||||||
try:
|
try:
|
||||||
state.container_manager.ensure_container(
|
state.container_manager.ensure_container(
|
||||||
record.username,
|
record.username,
|
||||||
@ -211,8 +222,15 @@ def login():
|
|||||||
|
|
||||||
@auth_bp.route('/host-login', methods=['POST'])
|
@auth_bp.route('/host-login', methods=['POST'])
|
||||||
def host_login():
|
def host_login():
|
||||||
"""宿主机模式一键进入(仅当 TERMINAL_SANDBOX_MODE=host 时可用)。"""
|
"""宿主机模式一键进入(仅当 TERMINAL_SANDBOX_MODE=host 时可用)。
|
||||||
if (TERMINAL_SANDBOX_MODE or "").lower() != "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
|
return jsonify({"success": False, "error": tr("auth.host_mode_disabled")}), 403
|
||||||
if not state.container_manager.has_capacity("host"):
|
if not state.container_manager.has_capacity("host"):
|
||||||
return jsonify({"success": False, "error": tr("auth.resource_busy")}), 503
|
return jsonify({"success": False, "error": tr("auth.resource_busy")}), 503
|
||||||
@ -293,14 +311,22 @@ def register():
|
|||||||
invite_code = (data.get('invite_code') or '').strip()
|
invite_code = (data.get('invite_code') or '').strip()
|
||||||
|
|
||||||
from .security import get_client_ip
|
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:
|
if limited:
|
||||||
return jsonify({"success": False, "error": tr("auth.register_rate_limited"), "retry_after": retry_after}), 429
|
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:
|
try:
|
||||||
state.user_manager.register_user(username, email, password, invite_code)
|
state.user_manager.register_user(username, email, password, invite_code)
|
||||||
auth_debug_log(f"[auth_debug] POST /register success username={username}")
|
auth_debug_log(f"[auth_debug] POST /register success username={username}")
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
except ValueError as exc:
|
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
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"success": False, "error": str(exc)}), 500
|
return jsonify({"success": False, "error": str(exc)}), 500
|
||||||
|
|||||||
@ -4,8 +4,6 @@ import json, time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from io import BytesIO
|
|
||||||
import zipfile
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, session, send_file
|
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
|
download_name=full_path.name
|
||||||
)
|
)
|
||||||
|
|
||||||
@chat_bp.route('/api/download/folder')
|
# 文件夹打包下载功能已彻底移除(2026-09-02 安全审计):
|
||||||
@api_login_required
|
# 原 /api/download/folder 端点在打包时不对 rglob 子项做符号链接/越界校验,
|
||||||
@with_terminal
|
# 攻击者在容器工作区内创建指向宿主绝对路径的文件型符号链接即可经 zip 泄露宿主任意文件
|
||||||
def download_folder_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
# (含 settings.json / .env / 其他用户数据)。前端已无调用,功能直接下线。
|
||||||
"""打包并下载文件夹"""
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 文件预览允许 inline 展示的 MIME 前缀白名单
|
# 文件预览允许 inline 展示的 MIME 前缀白名单
|
||||||
@ -314,13 +280,16 @@ _FILE_CONTENT_INLINE_TEXT_EXTS = {
|
|||||||
|
|
||||||
def _resolve_inline_mime(full_path: Path) -> str:
|
def _resolve_inline_mime(full_path: Path) -> str:
|
||||||
"""猜测 inline 预览用的 MIME 类型,不在白名单内的退化为 octet-stream。"""
|
"""猜测 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))
|
guessed, _ = mimetypes.guess_type(str(full_path))
|
||||||
if guessed:
|
if guessed:
|
||||||
if guessed.startswith(_FILE_CONTENT_INLINE_MIME_PREFIXES):
|
if guessed.startswith(_FILE_CONTENT_INLINE_MIME_PREFIXES):
|
||||||
return guessed
|
return guessed
|
||||||
return 'application/octet-stream'
|
return 'application/octet-stream'
|
||||||
# mimetypes 未识别,按扩展名兑底
|
# mimetypes 未识别,按扩展名兑底
|
||||||
suffix = full_path.suffix.lower()
|
|
||||||
if suffix in _FILE_CONTENT_INLINE_TEXT_EXTS:
|
if suffix in _FILE_CONTENT_INLINE_TEXT_EXTS:
|
||||||
return 'text/plain; charset=utf-8'
|
return 'text/plain; charset=utf-8'
|
||||||
if suffix == '.pdf':
|
if suffix == '.pdf':
|
||||||
|
|||||||
@ -71,7 +71,7 @@ def get_monitor_snapshot_api():
|
|||||||
stage = (request.args.get('stage') or 'before').lower()
|
stage = (request.args.get('stage') or 'before').lower()
|
||||||
if stage not in {'before', 'after'}:
|
if stage not in {'before', 'after'}:
|
||||||
stage = 'before'
|
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:
|
if not snapshot:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
|
|||||||
@ -944,7 +944,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
|
|||||||
snapshot_path = resolve_monitor_path(arguments)
|
snapshot_path = resolve_monitor_path(arguments)
|
||||||
monitor_snapshot = capture_monitor_snapshot(web_terminal.file_manager, snapshot_path, MONITOR_SNAPSHOT_CHAR_LIMIT, debug_log)
|
monitor_snapshot = capture_monitor_snapshot(web_terminal.file_manager, snapshot_path, MONITOR_SNAPSHOT_CHAR_LIMIT, debug_log)
|
||||||
if monitor_snapshot:
|
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:
|
elif function_name in MONITOR_MEMORY_TOOLS:
|
||||||
memory_snapshot_type = (arguments.get('memory_type') or 'main').lower()
|
memory_snapshot_type = (arguments.get('memory_type') or 'main').lower()
|
||||||
before_entries = None
|
before_entries = None
|
||||||
@ -957,7 +957,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
|
|||||||
'memory_type': memory_snapshot_type,
|
'memory_type': memory_snapshot_type,
|
||||||
'entries': before_entries
|
'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', {
|
sender('tool_start', {
|
||||||
'id': tool_display_id,
|
'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
|
update_payload['awaiting_content'] = True
|
||||||
if monitor_snapshot_after:
|
if monitor_snapshot_after:
|
||||||
update_payload['monitor_snapshot_after'] = 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)
|
sender('update_action', update_payload)
|
||||||
|
|
||||||
|
|||||||
@ -640,6 +640,13 @@ def apply_conversation_overrides(terminal: WebTerminal, workspace, conversation_
|
|||||||
meta = data.get("metadata") or {}
|
meta = data.get("metadata") or {}
|
||||||
prompt_name = meta.get("custom_prompt_name")
|
prompt_name = meta.get("custom_prompt_name")
|
||||||
personalization_name = meta.get("personalization_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
|
# prompt override
|
||||||
if prompt_name:
|
if prompt_name:
|
||||||
prompt_path = Path(workspace.data_dir) / "prompts" / f"{prompt_name}.txt"
|
prompt_path = Path(workspace.data_dir) / "prompts" / f"{prompt_name}.txt"
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import zipfile
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
|
|
||||||
@ -78,16 +76,9 @@ def gui_download_entry(terminal, workspace, username):
|
|||||||
try:
|
try:
|
||||||
target = manager.prepare_download(path)
|
target = manager.prepare_download(path)
|
||||||
if target.is_dir():
|
if target.is_dir():
|
||||||
memory_file = BytesIO()
|
# 文件夹打包下载已下线(2026-09-02 安全审计:os.walk/zipfile 对文件型符号链接
|
||||||
with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
|
# 会读取链接目标内容,容器内可借此泄露宿主任意文件)
|
||||||
for root, dirs, files in os.walk(target):
|
return jsonify({"success": False, "error": tr("files.folder_download_removed")}), 410
|
||||||
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')
|
|
||||||
return send_file(target, as_attachment=True, download_name=target.name)
|
return send_file(target, as_attachment=True, download_name=target.name)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return jsonify({"success": False, "error": str(exc)}), 400
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
|||||||
@ -7,12 +7,22 @@ from .state import MONITOR_SNAPSHOT_CACHE, MONITOR_SNAPSHOT_CACHE_LIMIT
|
|||||||
__all__ = ["cache_monitor_snapshot", "get_cached_monitor_snapshot"]
|
__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'):
|
if not execution_id or not snapshot or not snapshot.get('content'):
|
||||||
return
|
return
|
||||||
|
cache_key = _cache_key(execution_id, username)
|
||||||
|
if not cache_key:
|
||||||
|
return
|
||||||
normalized_stage = 'after' if stage == 'after' else 'before'
|
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,
|
'before': None,
|
||||||
'after': None,
|
'after': None,
|
||||||
'path': snapshot.get('path'),
|
'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['path'] = snapshot.get('path') or entry.get('path')
|
||||||
entry['timestamp'] = time.time()
|
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:
|
if len(MONITOR_SNAPSHOT_CACHE) > MONITOR_SNAPSHOT_CACHE_LIMIT:
|
||||||
try:
|
try:
|
||||||
oldest_key = min(
|
oldest_key = min(
|
||||||
@ -37,10 +47,11 @@ def cache_monitor_snapshot(execution_id: Optional[str], stage: str, snapshot: Op
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_cached_monitor_snapshot(execution_id: Optional[str], stage: str) -> Optional[Dict[str, Any]]:
|
def get_cached_monitor_snapshot(execution_id: Optional[str], stage: str, username: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
if not execution_id:
|
cache_key = _cache_key(execution_id, username)
|
||||||
|
if not cache_key:
|
||||||
return None
|
return None
|
||||||
entry = MONITOR_SNAPSHOT_CACHE.get(execution_id)
|
entry = MONITOR_SNAPSHOT_CACHE.get(cache_key)
|
||||||
if not entry:
|
if not entry:
|
||||||
return None
|
return None
|
||||||
normalized_stage = 'after' if stage == 'after' else 'before'
|
normalized_stage = 'after' if stage == 'after' else 'before'
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""安全相关工具:限流、CSRF、Socket Token、工具结果压缩等。"""
|
"""安全相关工具:限流、CSRF、Socket Token、工具结果压缩等。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import hmac
|
import hmac
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, Optional, Tuple
|
from typing import Dict, Any, Optional, Tuple
|
||||||
@ -9,13 +10,34 @@ from functools import wraps
|
|||||||
|
|
||||||
from . import state
|
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:
|
def get_client_ip() -> str:
|
||||||
"""获取客户端IP,支持 X-Forwarded-For."""
|
"""获取客户端IP。仅当直接对端是可信代理时才采信 X-Forwarded-For。"""
|
||||||
forwarded = request.headers.get("X-Forwarded-For")
|
remote = request.remote_addr or "unknown"
|
||||||
if forwarded:
|
if remote in _TRUSTED_PROXIES:
|
||||||
return forwarded.split(",")[0].strip()
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
return request.remote_addr or "unknown"
|
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:
|
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])
|
retry_after = window_seconds - int(now - bucket[0])
|
||||||
return True, max(retry_after, 1)
|
return True, max(retry_after, 1)
|
||||||
bucket.append(now)
|
bucket.append(now)
|
||||||
|
_gc_rate_limit_buckets(now)
|
||||||
return False, 0
|
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 rate_limited(action: str, limit: int, window_seconds: int, scope: str = "ip", error_message: Optional[str] = None):
|
||||||
"""装饰器:为路由增加速率限制。"""
|
"""装饰器:为路由增加速率限制。"""
|
||||||
def decorator(func):
|
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:
|
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)
|
ident = resolve_identifier(scope, identifier, kwargs)
|
||||||
key = f"{action}:{ident}"
|
key = f"{action}:{ident}"
|
||||||
now = time.time()
|
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):
|
def attach_security_hooks(app):
|
||||||
"""注册 CSRF 校验与通用安全响应头。"""
|
"""注册 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
|
@app.before_request
|
||||||
def _enforce_csrf_token():
|
def _enforce_csrf_token():
|
||||||
method = (request.method or "GET").upper()
|
method = (request.method or "GET").upper()
|
||||||
|
|||||||
@ -74,6 +74,15 @@ def get_app_version_info():
|
|||||||
|
|
||||||
@status_bp.route('/api/app/apk/latest')
|
@status_bp.route('/api/app/apk/latest')
|
||||||
def download_latest_apk():
|
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
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
apk_path = _resolve_android_apk_path(project_root)
|
apk_path = _resolve_android_apk_path(project_root)
|
||||||
if not apk_path.exists():
|
if not apk_path.exists():
|
||||||
|
|||||||
@ -114,6 +114,9 @@ def create_task_api():
|
|||||||
workspace_id = session.get("workspace_id") or "default"
|
workspace_id = session.get("workspace_id") or "default"
|
||||||
payload = request.get_json() or {}
|
payload = request.get_json() or {}
|
||||||
message = (payload.get("message") or "").strip()
|
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 [])
|
images, videos = _normalize_media_payload(payload.get("images") or [], payload.get("videos") or [])
|
||||||
files = _normalize_files_payload(payload.get("files"))
|
files = _normalize_files_payload(payload.get("files"))
|
||||||
conversation_id = payload.get("conversation_id")
|
conversation_id = payload.get("conversation_id")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user