agent-Specialization/server/status/file_open.py
JOJO 57043b0084 fix(security): 第二轮全量 API 审计修复(角色穿越/宿主 GUI/限流收口)
背景:对全部 206 个 HTTP 路由 + SocketIO 事件逐个排查(主审 + 2 个子
智能体初筛后复核),另按用户要求专项审计前端渲染面(无新发现)。
完整报告见 _experiments/security_audit_2026-09-02/REPORT.md 第八章。

新漏洞修复:
- 多智能体角色 role_id 路径穿越(高):save_custom_role 直接拼接
  "{role_id}.md",POST body 的 role_id 无任何校验,可 ../ 穿越覆盖
  其他用户角色文件(跨用户提示词注入),本地已复现写入成功。
  修复:新增 validate_role_id 白名单(^[a-z0-9][a-z0-9_-]{0,63}$),
  role_store 存储层 + multi_agent API 层(POST/PUT/DELETE)双保险
- open-in-file-manager 漏 _is_host_mode_request 检查(同文件其余三个
  端点都有):docker 模式下任意登录用户可触发宿主 GUI 弹窗+探测路径。
  修复:补 host 检查,docker 下 403
- /api/admin/secondary/verify 无限流且 CSRF 豁免:admin 会话前提下
  可在线爆破二级密码(纵深防御缺失)。修复:5 次/300s 用户维度限流
- WS client_chunk_log / client_stream_debug_log 无连接认证检查且
  无限流(日志写盘放大面)。修复:必须已认证连接 + 30 次/60s 滑窗
  + 桶表万级上限回收
- _get_conversation_file_path 直接拼 "{id}.json"(conv_ 前缀恰好阻碍
  直接穿越,属防御深度缺失)。修复:^conv_[A-Za-z0-9_-]+$ 白名单,
  已核验全部 5 个调用点与 temp_ 前缀排除路径不受影响
- /api/conversations/media/<id> 采信 entry mime_type 并 inline 返回。
  修复:text/html、image/svg+xml、xhtml 强制 octet-stream + attachment
  (与 file/content 的 SVG 策略对齐,防存储型 XSS 一致性收口)
- /api/status 每次心跳返回宿主绝对 project_path(泄露系统用户名与
  目录布局)。修复:docker 模式脱敏为容器视角 /workspace,host 不变

LLM 成本攻击止血(第一轮发现 5 的端点级落地):
- POST /api/tasks(GUI 发消息主入口):30 次/60s/user
- POST /api/conversations/<id>/compress(调 api_client.chat 做摘要):
  5 次/300s/user
- 注:对话回顾 review 端点实为纯本地 Markdown 生成(不调 LLM),
  其「发送给模型」模式走 /api/tasks,已被上述限流覆盖;
  按 token 计费的完整配额方案仍遗留待产品决策

验证:全部 py_compile 通过;冒烟测试 6/6;validate_role_id 8 个恶意
样本全拒 + 3 个合法样本放行 + 穿越写入拦截回归通过;conv_id 白名单
4 组样本符合预期。端点级行为待服务重启后实测。

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-02 19:37:34 +08:00

435 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from server.status import status_bp
from server.status.base import _is_host_mode_request
from server.status.git import _run_project_git
import time
import re
import os
import json
import shutil
import subprocess
import sys
import tempfile
import plistlib
from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, session
from server.auth_helpers import api_login_required, resolve_admin_policy
from server.context import with_terminal, attach_user_broadcast
from server.state import (
PROJECT_STORAGE_CACHE,
PROJECT_STORAGE_CACHE_TTL_SECONDS,
PROJECT_MAX_STORAGE_MB,
container_manager,
user_manager,
)
from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE
from modules.host_workspace_manager import (
create_host_workspace,
delete_host_workspace,
load_host_workspace_catalog,
rename_host_workspace,
resolve_host_workspace,
set_default_host_workspace,
)
from utils.host_workspace_debug import write_host_workspace_debug
from server.utils_common import log_conn_diag
import server.state as state
from modules.i18n import tr
def _open_path_in_file_manager(path: Path) -> bool:
target = path.expanduser().resolve()
if not target.exists():
return False
try:
if sys.platform == "darwin":
subprocess.Popen(["open", str(target)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
if os.name == "nt":
os.startfile(str(target)) # type: ignore[attr-defined]
return True
opener = shutil.which("xdg-open")
if opener:
subprocess.Popen([opener, str(target)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception:
return False
return False
def _resolve_project_git_file(workspace, rel_path: str) -> tuple[Path | None, str]:
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
repo_root = Path(root_text).expanduser().resolve() if ok and root_text else project_path
try:
target = (repo_root / rel_path).expanduser().resolve()
target.relative_to(repo_root)
except Exception:
return None, tr("status_file_open.invalid_path")
if not target.exists() or not target.is_file():
return None, tr("status_file_open.file_not_found")
return target, ""
def _mac_open_app_candidates(file_path: Path | None = None) -> list[dict]:
app_paths: list[Path] = []
default_app_path = ""
if file_path is not None:
try:
from AppKit import NSWorkspace
from Foundation import NSURL
file_url = NSURL.fileURLWithPath_(str(file_path))
workspace = NSWorkspace.sharedWorkspace()
urls = workspace.URLsForApplicationsToOpenURL_(file_url) or []
app_paths.extend(Path(str(url.path())) for url in urls if url and url.path())
default_url = workspace.URLForApplicationToOpenURL_(file_url)
if default_url and default_url.path():
default_app_path = str(Path(str(default_url.path())).expanduser().absolute())
except Exception:
app_paths = []
if not app_paths:
# 只在 LaunchServices 不可用时做很小的兜底;正常路径必须使用系统针对该文件的结果。
for base in ["/System/Applications/TextEdit.app", "/Applications/Xcode.app", "/Applications/Cursor.app"]:
path = Path(base).expanduser()
if path.exists():
app_paths.append(path)
seen = set()
apps: list[dict] = []
for path in app_paths:
try:
visible_path = path.expanduser().absolute()
except Exception:
continue
path_text = str(visible_path)
if (
path_text in seen
or "__pycache__" in path_text
or "/Library/Application Support/" in path_text
or "/System/Library/PrivateFrameworks/" in path_text
or "/System/Library/CoreServices/" in path_text
or "/System/Library/Services/" in path_text
):
continue
info = _read_mac_app_info(visible_path)
label = info.get("label") or visible_path.stem
bundle_id = str(info.get("bundle_id") or "")
if bundle_id == "com.microsoft.VSCode" or label == "Code":
label = visible_path.stem
label_lower = label.lower()
if label_lower.endswith("agent") or label_lower in {"finder", "system settings"}:
continue
seen.add(path_text)
apps.append({
"id": path_text,
"label": f"{label}(默认)" if default_app_path and path_text == default_app_path else label,
"bundle_id": bundle_id,
"icon_url": f"/api/project/app-icon?app_id={path_text}",
"rank": 0 if default_app_path and path_text == default_app_path else 1,
})
apps.sort(key=lambda item: (item.get("rank", 1000), item.get("label", "").lower()))
return [{k: v for k, v in item.items() if k != "rank"} for item in apps[:40]]
def _read_mac_app_info(app_path: Path) -> dict:
info_path = app_path / "Contents" / "Info.plist"
try:
data = plistlib.loads(info_path.read_bytes())
except Exception:
data = {}
return {
"label": data.get("CFBundleDisplayName") or data.get("CFBundleName") or app_path.stem,
"bundle_id": data.get("CFBundleIdentifier") or "",
"icon_file": data.get("CFBundleIconFile") or "",
}
def _mac_app_icon_path(app_path: Path) -> Path | None:
info = _read_mac_app_info(app_path)
icon_name = str(info.get("icon_file") or "").strip()
resources = app_path / "Contents" / "Resources"
candidates = []
if icon_name:
candidates.append(resources / icon_name)
if not icon_name.endswith(".icns"):
candidates.append(resources / f"{icon_name}.icns")
candidates.extend(resources.glob("*.icns"))
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _windows_open_app_candidates(file_path: Path | None = None) -> list[dict]:
if os.name != "nt" or file_path is None:
return []
try:
import ctypes
import shlex
import winreg
except Exception:
return []
ext = file_path.suffix.lower()
if not ext:
return []
def expand_env(value: str) -> str:
try:
return os.path.expandvars(value)
except Exception:
return value
def parse_command_exe(command: str) -> str:
command = expand_env((command or "").strip())
if not command:
return ""
try:
parts = shlex.split(command, posix=False)
if parts:
return parts[0].strip('"')
except Exception:
pass
if command.startswith('"'):
end = command.find('"', 1)
return command[1:end] if end > 1 else ""
return command.split(" ", 1)[0].strip('"')
def read_reg_value(root, subkey: str, value_name: str = "") -> str:
try:
with winreg.OpenKey(root, subkey) as key:
value, _ = winreg.QueryValueEx(key, value_name)
return str(value or "")
except Exception:
return ""
def enum_subkey_values(root, subkey: str) -> list[str]:
values: list[str] = []
try:
with winreg.OpenKey(root, subkey) as key:
idx = 0
while True:
try:
name, value, _ = winreg.EnumValue(key, idx)
idx += 1
if name and name != "MRUList":
values.append(str(value or name))
except OSError:
break
except Exception:
pass
return values
def command_for_progid(progid: str) -> str:
for root in (winreg.HKEY_CURRENT_USER, winreg.HKEY_CLASSES_ROOT, winreg.HKEY_LOCAL_MACHINE):
for prefix in ("Software\\Classes\\", ""):
command = read_reg_value(root, f"{prefix}{progid}\\shell\\open\\command")
if command:
return command
return ""
def app_label(exe: Path, default: bool = False) -> str:
stem = exe.stem
label_map = {
"notepad": "记事本",
"code": "Visual Studio Code",
"cursor": "Cursor",
"wordpad": "写字板",
}
label = label_map.get(stem.lower(), stem)
return f"{label}(默认)" if default else label
candidates: list[tuple[str, bool]] = []
# 默认打开程序Windows Shell 关联查询,等价于系统“打开方式”的默认项。
try:
ASSOCF_NONE = 0
ASSOCSTR_EXECUTABLE = 2
buffer_len = ctypes.c_ulong(0)
ctypes.windll.Shlwapi.AssocQueryStringW(
ASSOCF_NONE, ASSOCSTR_EXECUTABLE, ext, None, None, ctypes.byref(buffer_len)
)
if buffer_len.value > 0:
buffer = ctypes.create_unicode_buffer(buffer_len.value)
result = ctypes.windll.Shlwapi.AssocQueryStringW(
ASSOCF_NONE, ASSOCSTR_EXECUTABLE, ext, None, buffer, ctypes.byref(buffer_len)
)
if result == 0 and buffer.value:
candidates.append((buffer.value, True))
except Exception:
pass
progids = set()
for root, subkey in (
(winreg.HKEY_CURRENT_USER, f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\OpenWithProgids"),
(winreg.HKEY_CLASSES_ROOT, f"{ext}\\OpenWithProgids"),
):
progids.update(enum_subkey_values(root, subkey))
default_progid = read_reg_value(winreg.HKEY_CLASSES_ROOT, ext)
if default_progid:
progids.add(default_progid)
user_choice = read_reg_value(
winreg.HKEY_CURRENT_USER,
f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\UserChoice",
"ProgId",
)
if user_choice:
progids.add(user_choice)
for progid in progids:
command = command_for_progid(progid)
exe = parse_command_exe(command)
if exe:
candidates.append((exe, False))
app_names = enum_subkey_values(
winreg.HKEY_CURRENT_USER,
f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\OpenWithList",
)
app_names += enum_subkey_values(
winreg.HKEY_CLASSES_ROOT,
f"{ext}\\OpenWithList",
)
for app_name in app_names:
app_key = f"Applications\\{app_name}\\shell\\open\\command"
command = read_reg_value(winreg.HKEY_CLASSES_ROOT, app_key)
exe = parse_command_exe(command)
if exe:
candidates.append((exe, False))
seen = set()
apps = []
for exe, is_default in candidates:
try:
path = Path(expand_env(exe)).expanduser().resolve()
except Exception:
continue
key = str(path).lower()
if key in seen or not path.exists() or path.suffix.lower() != ".exe":
continue
seen.add(key)
apps.append({"id": str(path), "label": app_label(path, is_default)})
return apps[:40]
def _open_file_with_app(file_path: Path, app_id: str) -> bool:
try:
if sys.platform == "darwin":
app_path = Path(app_id).expanduser().resolve()
if not app_path.exists() or app_path.suffix != ".app":
return False
subprocess.Popen(["open", "-a", str(app_path), str(file_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
if os.name == "nt":
exe_path = Path(app_id).expanduser().resolve()
if not exe_path.exists() or exe_path.suffix.lower() != ".exe":
return False
subprocess.Popen([str(exe_path), str(file_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception:
return False
return False
@status_bp.route('/api/project/open-in-file-manager', methods=['POST'])
@api_login_required
@with_terminal
def open_project_in_file_manager(terminal, workspace, username):
# 在宿主 GUI 上弹文件管理器窗口——仅 host单机模式合理
# docker/web 多用户模式下这会让任何登录用户在服务器桌面弹窗(骚扰 + 路径存在性探测)
if not _is_host_mode_request():
return jsonify({"success": False, "error": tr("status_file_open.host_mode_only")}), 403
data = request.get_json(silent=True) or {}
target_workspace_id = (data.get("workspace_id") or "").strip()
if target_workspace_id:
if _is_host_mode_request():
catalog, _ = resolve_host_workspace()
target = next(
(item for item in (catalog.get("workspaces") or [])
if item.get("workspace_id") == target_workspace_id),
None,
)
if not target:
return jsonify({"success": False, "error": tr("status_file_open.workspace_not_found")}), 404
project_path = Path(target.get("path") or "").expanduser().resolve()
else:
if target_workspace_id not in user_manager.list_user_workspaces(username):
return jsonify({"success": False, "error": tr("status_file_open.project_not_found")}), 404
ws_obj = user_manager.ensure_user_workspace(username, target_workspace_id)
project_path = Path(getattr(ws_obj, "project_path", "") or "").expanduser().resolve()
else:
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
target = Path(root_text).expanduser().resolve() if ok and root_text else project_path
if _open_path_in_file_manager(target):
return jsonify({"success": True})
return jsonify({"success": False, "error": tr("status_file_open.cannot_open_file_manager")}), 500
@status_bp.route('/api/project/file-open-apps')
@api_login_required
@with_terminal
def list_project_file_open_apps(terminal, workspace, username):
if not _is_host_mode_request():
return jsonify({"success": False, "error": tr("status_file_open.host_mode_only")}), 403
rel_path = request.args.get("path", "").strip()
target, error = _resolve_project_git_file(workspace, rel_path)
if not target:
return jsonify({"success": False, "error": error or tr("status_file_open.invalid_path")}), 400
if sys.platform == "darwin":
apps = _mac_open_app_candidates(target)
elif os.name == "nt":
apps = _windows_open_app_candidates(target)
else:
apps = []
return jsonify({"success": True, "data": {"apps": apps}})
@status_bp.route('/api/project/app-icon')
@api_login_required
def get_project_open_app_icon():
if not _is_host_mode_request():
return jsonify({"success": False, "error": tr("status_file_open.host_mode_only")}), 403
app_id = request.args.get("app_id", "").strip()
if sys.platform != "darwin" or not app_id:
return jsonify({"success": False, "error": tr("status_file_open.app_icon_unavailable")}), 404
try:
app_path = Path(app_id).expanduser().resolve()
if not app_path.exists() or app_path.suffix != ".app":
return jsonify({"success": False, "error": tr("status_file_open.app_not_found")}), 404
icon_path = _mac_app_icon_path(app_path)
if not icon_path:
return jsonify({"success": False, "error": tr("status_file_open.app_icon_not_found")}), 404
cache_dir = Path(tempfile.gettempdir()) / "agents_app_icons"
cache_dir.mkdir(parents=True, exist_ok=True)
out_path = cache_dir / f"{abs(hash(str(app_path)))}.png"
if not out_path.exists() or out_path.stat().st_mtime < icon_path.stat().st_mtime:
sips = shutil.which("sips")
if not sips:
return jsonify({"success": False, "error": tr("status_file_open.icon_tool_unavailable")}), 404
subprocess.run(
[sips, "-s", "format", "png", str(icon_path), "--out", str(out_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
check=False,
)
if out_path.exists():
return send_file(str(out_path), mimetype="image/png")
except Exception:
pass
return jsonify({"success": False, "error": tr("status_file_open.app_icon_unavailable")}), 404
@status_bp.route('/api/project/open-file-with-app', methods=['POST'])
@api_login_required
@with_terminal
def open_project_file_with_app(terminal, workspace, username):
if not _is_host_mode_request():
return jsonify({"success": False, "error": tr("status_file_open.host_mode_only")}), 403
payload = request.get_json(silent=True) or {}
rel_path = str(payload.get("path") or "").strip()
app_id = str(payload.get("app_id") or "").strip()
if not app_id:
return jsonify({"success": False, "error": tr("status_file_open.app_not_selected")}), 400
target, error = _resolve_project_git_file(workspace, rel_path)
if not target:
return jsonify({"success": False, "error": error or tr("status_file_open.invalid_path")}), 400
available = _mac_open_app_candidates(target) if sys.platform == "darwin" else _windows_open_app_candidates(target) if os.name == "nt" else []
if not any(item.get("id") == app_id for item in available):
return jsonify({"success": False, "error": tr("status_file_open.app_unavailable")}), 400
if _open_file_with_app(target, app_id):
return jsonify({"success": True})
return jsonify({"success": False, "error": tr("status_file_open.open_file_failed")}), 500