agent-Specialization/server/chat/files.py
JOJO 596e781555 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)。
2026-09-02 18:31:00 +08:00

330 lines
12 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.chat import chat_bp
import json, time
from datetime import datetime
from typing import Dict, Any, Optional
from pathlib import Path
import os
from flask import Blueprint, jsonify, request, session, send_file
from werkzeug.utils import secure_filename
from werkzeug.exceptions import RequestEntityTooLarge
import secrets
import mimetypes
from config import MAX_UPLOAD_SIZE, OUTPUT_FORMATS
from modules.personalization_manager import (
load_personalization_config,
resolve_context_compression_settings,
save_personalization_config,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
)
from modules.skills_manager import (
get_skills_catalog,
infer_private_skills_dir,
merge_enabled_skills,
sync_workspace_skills,
)
from modules.upload_security import UploadSecurityError
from modules.host_sandbox_policy import load_policy, save_policy
from modules.user_manager import UserWorkspace
from core.web_terminal import WebTerminal
from config.model_profiles import get_model_context_window
from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker
from server.security import rate_limited, prune_socket_tokens
from server.utils_common import debug_log
from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.utils_common import sanitize_filename_preserve_unicode
from modules.i18n import tr
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
def _derive_filename_from_upload(uploaded_file) -> str:
"""当上传请求未提供文件名时,根据 MIME 类型或文件扩展名推断一个兜底文件名。"""
mime_type = (uploaded_file.content_type or '').strip().lower()
if mime_type:
# 优先使用 mimetypes 库的 guess_extension它通常比硬编码映射更全面
ext = mimetypes.guess_extension(mime_type, strict=False)
if ext:
return f"upload_{int(time.time())}{ext}"
# 常见类型的兜底映射
type_map = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/bmp': '.bmp',
'image/svg+xml': '.svg',
'video/mp4': '.mp4',
'video/quicktime': '.mov',
'video/x-matroska': '.mkv',
'video/avi': '.avi',
'video/webm': '.webm',
'application/pdf': '.pdf',
'text/plain': '.txt',
'text/markdown': '.md',
}
ext = type_map.get(mime_type)
if ext:
return f"upload_{int(time.time())}{ext}"
return ''
@chat_bp.route('/api/upload', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("legacy_upload", 20, 300, scope="user")
def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""处理前端文件上传请求"""
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_upload"):
return jsonify({
"success": False,
"error": tr("chat_files.upload_admin_disabled"),
"message": tr("chat_files.upload_admin_disabled_message")
}), 403
if 'file' not in request.files:
return jsonify({
"success": False,
"error": tr("chat_files.file_not_found_in_request"),
"message": tr("chat_files.missing_file_field")
}), 400
uploaded_file = request.files['file']
original_name = (request.form.get('filename') or '').strip()
if not uploaded_file:
return jsonify({
"success": False,
"error": tr("chat_files.file_not_found_in_request"),
"message": tr("chat_files.missing_file_content")
}), 400
raw_name = original_name or uploaded_file.filename or ''
if not raw_name.strip():
# 文件名缺失时,尝试根据 MIME 类型或文件内容推断一个兜底文件名
raw_name = _derive_filename_from_upload(uploaded_file)
if not raw_name.strip():
return jsonify({
"success": False,
"error": tr("chat_files.empty_filename"),
"message": tr("chat_files.choose_file")
}), 400
filename = sanitize_filename_preserve_unicode(raw_name)
if not filename:
filename = secure_filename(raw_name)
if not filename:
return jsonify({
"success": False,
"error": tr("chat_files.invalid_filename"),
"message": tr("chat_files.unsupported_filename_chars")
}), 400
file_manager = getattr(terminal, 'file_manager', None)
if file_manager is None:
return jsonify({
"success": False,
"error": tr("chat_files.file_manager_uninitialized")
}), 500
target_folder_relative = UPLOAD_FOLDER_NAME
valid_folder, folder_error, folder_path = file_manager._validate_path(target_folder_relative)
if not valid_folder:
return jsonify({
"success": False,
"error": folder_error
}), 400
try:
folder_path.mkdir(parents=True, exist_ok=True)
except Exception as exc:
return jsonify({
"success": False,
"error": tr("chat_files.create_upload_dir_failed", error=exc)
}), 500
target_relative = str(Path(target_folder_relative) / filename)
valid_file, file_error, target_full_path = file_manager._validate_path(target_relative)
if not valid_file:
return jsonify({
"success": False,
"error": file_error
}), 400
final_path = target_full_path
if final_path.exists():
stem = final_path.stem
suffix = final_path.suffix
counter = 1
while final_path.exists():
candidate_name = f"{stem}_{counter}{suffix}"
target_relative = str(Path(target_folder_relative) / candidate_name)
valid_file, file_error, candidate_path = file_manager._validate_path(target_relative)
if not valid_file:
return jsonify({
"success": False,
"error": file_error
}), 400
final_path = candidate_path
counter += 1
try:
relative_path = str(final_path.relative_to(workspace.project_path))
except Exception as exc:
return jsonify({
"success": False,
"error": tr("chat_files.path_resolve_failed", error=exc)
}), 400
guard = get_upload_guard(workspace)
try:
result = guard.process_upload(
uploaded_file,
final_path,
username=username,
source="legacy_upload",
original_name=raw_name,
relative_path=relative_path,
)
except UploadSecurityError as exc:
return build_upload_error_response(exc)
except Exception as exc:
return jsonify({
"success": False,
"error": tr("chat_files.save_failed", error=exc)
}), 500
metadata = result.get("metadata", {})
print(f"{OUTPUT_FORMATS['file']} 上传文件: {relative_path}")
return jsonify({
"success": True,
"path": relative_path,
"filename": final_path.name,
"folder": target_folder_relative,
"scan": metadata.get("scan"),
"sha256": metadata.get("sha256"),
"size": metadata.get("size"),
})
@chat_bp.errorhandler(RequestEntityTooLarge)
def handle_file_too_large(error):
"""全局捕获上传超大小"""
size_mb = MAX_UPLOAD_SIZE / (1024 * 1024)
return jsonify({
"success": False,
"error": tr("chat_files.file_too_large"),
"message": tr("chat_files.file_too_large_message", size_mb=size_mb)
}), 413
@chat_bp.route('/api/download/file')
@api_login_required
@with_terminal
def download_file_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_file():
return jsonify({"success": False, "error": tr("chat_files.file_not_found")}), 404
return send_file(
full_path,
as_attachment=True,
download_name=full_path.name
)
# 文件夹打包下载功能已彻底移除2026-09-02 安全审计):
# 原 /api/download/folder 端点在打包时不对 rglob 子项做符号链接/越界校验,
# 攻击者在容器工作区内创建指向宿主绝对路径的文件型符号链接即可经 zip 泄露宿主任意文件
# (含 settings.json / .env / 其他用户数据)。前端已无调用,功能直接下线。
# 文件预览允许 inline 展示的 MIME 前缀白名单
# 任何不在此白名单内的类型一律退化为 application/octet-stream浏览器会触发下载
_FILE_CONTENT_INLINE_MIME_PREFIXES = (
'text/',
'image/',
'application/pdf',
'application/json',
'application/javascript',
'application/xml',
)
# 允许 inline 预览的文本类扩展名mimetypes 未识别时兑底)
_FILE_CONTENT_INLINE_TEXT_EXTS = {
'.txt', '.md', '.markdown', '.csv', '.json', '.json5', '.yaml', '.yml',
'.js', '.ts', '.jsx', '.tsx', '.vue', '.py', '.rb', '.go', '.rs',
'.java', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.swift', '.kt',
'.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd',
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.xml', '.svg', '.toml', '.ini', '.cfg', '.conf', '.log', '.sql',
}
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 未识别,按扩展名兑底
if suffix in _FILE_CONTENT_INLINE_TEXT_EXTS:
return 'text/plain; charset=utf-8'
if suffix == '.pdf':
return 'application/pdf'
return 'application/octet-stream'
@chat_bp.route('/api/file/content')
@api_login_required
@with_terminal
def file_content_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""以 inline 方式返回文件内容,用于前端预览(区别于 /api/download/file 的 attachment
路径校验复用 _validate_path与下载接口同等安全级别。
非白名单 MIME 退化为 octet-stream避免浏览器误执行 HTML/JS。"""
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_file():
return jsonify({"success": False, "error": tr("chat_files.file_not_found")}), 404
mime_type = _resolve_inline_mime(full_path)
# 永远不要让浏览器 inline 执行 HTML/JS防 XSS
# HTML 文件强制走下载,不 inline 预览
if mime_type.startswith('text/html'):
mime_type = 'application/octet-stream'
return send_file(
full_path,
mimetype=mime_type,
as_attachment=False
)