修复多项安全漏洞和Bug

1. 安全修复:
   - 修复密码重置接口用户枚举漏洞,统一返回消息防止信息泄露
   - 统一密码强度验证为8位以上且包含字母和数字
   - 添加第三方账号密码加密存储(Fernet对称加密)
   - 修复默认管理员弱密码问题,改用随机生成强密码
   - 修复管理员回复XSS漏洞,添加HTML转义
   - 将MD5哈希替换为SHA256

2. 并发Bug修复:
   - 修复日志缓存竞态条件,添加锁保护
   - 修复截图信号量配置变更后不生效问题

3. 其他改进:
   - 添加API参数类型验证和边界检查
   - 新增crypto_utils.py加密工具模块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-11 19:14:14 +08:00
parent 70cd95c366
commit a25c9fbba0
6 changed files with 293 additions and 70 deletions

119
app.py
View File

@@ -43,6 +43,7 @@ from app_security import (
is_safe_path, sanitize_filename, get_client_ip
)
from app_utils import verify_and_consume_captcha
from crypto_utils import encrypt_password as encrypt_account_password
# ========== 时区辅助函数 (Bug #2 fix) ==========
@@ -147,19 +148,25 @@ max_concurrent_global = config.MAX_CONCURRENT_GLOBAL
user_semaphores = {} # {user_id: Semaphore}
global_semaphore = threading.Semaphore(max_concurrent_global)
# 截图专用信号量:限制同时进行的截图任务数量为1(避免资源竞争)
# <EFBFBD><EFBFBD><EFBFBD>信号量将在首次使用时初始化
# 截图专用信号量:限制同时进行的截图任务数量(避免资源竞争)
# 信号量将在首次使用时初始化,并支持动态更新
screenshot_semaphore = None
screenshot_semaphore_lock = threading.Lock()
screenshot_semaphore_size = 0 # 记录当前信号量大小
def get_screenshot_semaphore():
"""获取截图信号量(懒加载,根据配置动态创建)"""
global screenshot_semaphore
"""获取截图信号量(懒加载,根据配置动态创建,支持配置更新"""
global screenshot_semaphore, screenshot_semaphore_size
with screenshot_semaphore_lock:
config = database.get_system_config()
max_concurrent = config.get('max_screenshot_concurrent', 3)
if screenshot_semaphore is None:
# 安全修复:支持配置变更后重新创建信号量
if screenshot_semaphore is None or screenshot_semaphore_size != max_concurrent:
screenshot_semaphore = threading.Semaphore(max_concurrent)
screenshot_semaphore_size = max_concurrent
print(f"[截图信号量] 已更新为 {max_concurrent} 并发")
return screenshot_semaphore, max_concurrent
@@ -357,36 +364,32 @@ def log_to_client(message, user_id=None, account_id=None):
# 如果指定了user_id,则缓存到该用户的日志
if user_id:
# 安全修复:使用锁保护日志缓存操作,防止竞态条件
global log_cache_total_count
if user_id not in log_cache:
log_cache[user_id] = []
log_cache[user_id].append(log_data)
log_cache_total_count += 1
with log_cache_lock:
if user_id not in log_cache:
log_cache[user_id] = []
log_cache[user_id].append(log_data)
log_cache_total_count += 1
# 持久化到数据库 (已禁用,使用task_logs表代替)
# try:
# database.save_operation_log(user_id, message, account_id, 'INFO')
# except Exception as e:
# print(f"保存日志到数据库失败: {e}")
# 单用户限制
if len(log_cache[user_id]) > MAX_LOGS_PER_USER:
log_cache[user_id].pop(0)
log_cache_total_count -= 1
# 单用户限制
if len(log_cache[user_id]) > MAX_LOGS_PER_USER:
log_cache[user_id].pop(0)
log_cache_total_count -= 1
# 全局限制 - 如果超过总数限制,清理日志最多的用户
while log_cache_total_count > MAX_TOTAL_LOGS:
if log_cache:
max_user = max(log_cache.keys(), key=lambda u: len(log_cache[u]))
if log_cache[max_user]:
log_cache[max_user].pop(0)
log_cache_total_count -= 1
# 全局限制 - 如果超过总数限制,清理日志最多的用户
while log_cache_total_count > MAX_TOTAL_LOGS:
if log_cache:
max_user = max(log_cache.keys(), key=lambda u: len(log_cache[u]))
if log_cache[max_user]:
log_cache[max_user].pop(0)
log_cache_total_count -= 1
else:
break
else:
break
else:
break
# 发送到该用户的room
# 发送到该用户的room(在锁外执行,避免死锁)
socketio.emit('log', log_data, room=f'user_{user_id}')
# 控制台日志:添加账号短标识便于区分
@@ -1135,8 +1138,10 @@ def admin_reset_password_route(user_id):
if not new_password:
return jsonify({"error": "新密码不能为空"}), 400
if len(new_password) < 6:
return jsonify({"error": "密码长度不能少于6位"}), 400
# 安全修复统一密码强度要求为8位以上且包含字母和数字
is_valid, error_msg = validate_password(new_password)
if not is_valid:
return jsonify({"error": error_msg}), 400
if database.admin_reset_user_password(user_id, new_password):
return jsonify({"message": "密码重置成功"})
@@ -1184,24 +1189,27 @@ def request_password_reset():
if not username or not new_password:
return jsonify({"error": "用户名和新密码不能为空"}), 400
if len(new_password) < 6:
return jsonify({"error": "密码长度不能少于6位"}), 400
# 安全修复统一密码强度要求为8位以上且包含字母和数字
is_valid, error_msg = validate_password(new_password)
if not is_valid:
return jsonify({"error": error_msg}), 400
# 验证用户存在
# 安全修复:防止用户枚举,统一返回成功消息
# 无论用户是否存在都返回相同消息
user = database.get_user_by_username(username)
if not user:
return jsonify({"error": "用户不存在"}), 404
# 如果提供邮箱,验证邮箱是否匹配
if email and user.get('email') != email:
return jsonify({"error": "邮箱不匹配"}), 400
# 如果用户存在且邮箱匹配(或未提供邮箱),则创建重置申请
if user:
# 如果提供了邮箱,验证邮箱是否匹配
if email and user.get('email') != email:
# 邮箱不匹配,但不透露具体原因
pass
else:
# 创建重置申请
database.create_password_reset_request(user['id'], new_password)
# 创建重置申请
request_id = database.create_password_reset_request(user['id'], new_password)
if request_id:
return jsonify({"message": "密码重置申请已提交,请等待管理员审核"})
else:
return jsonify({"error": "申请提交失败"}), 500
# 无论成功与否都返回相同消息,防止用户枚举
return jsonify({"message": "如果账号存在,密码重置申请已提交,请等待管理员审核"})
# ==================== 账号管理API (用户隔离) ====================
@@ -1274,8 +1282,16 @@ def get_my_feedbacks():
def get_all_feedbacks():
"""管理员获取所有反馈"""
status = request.args.get('status')
limit = int(request.args.get('limit', 100))
offset = int(request.args.get('offset', 0))
# 安全修复:参数类型验证,防止类型转换异常
try:
limit = int(request.args.get('limit', 100))
offset = int(request.args.get('offset', 0))
# 限制最大值防止DoS
limit = min(max(1, limit), 1000)
offset = max(0, offset)
except (ValueError, TypeError):
return jsonify({"error": "无效的分页参数"}), 400
feedbacks = database.get_bug_feedbacks(limit=limit, offset=offset, status_filter=status)
stats = database.get_feedback_stats()
@@ -1409,6 +1425,9 @@ def update_account(account_id):
if not new_password:
return jsonify({"error": "密码不能为空"}), 400
# 安全修复:加密存储第三方账号密码
encrypted_password = encrypt_account_password(new_password)
# 更新数据库
with db_pool.get_db() as conn:
cursor = conn.cursor()
@@ -1416,15 +1435,15 @@ def update_account(account_id):
UPDATE accounts
SET password = ?, remember = ?
WHERE id = ?
''', (new_password, new_remember, account_id))
''', (encrypted_password, new_remember, account_id))
conn.commit()
# 重置账号登录状态密码修改后恢复active状态
database.reset_account_login_status(account_id)
logger.info(f"[账号更新] 用户 {user_id} 修改了账号 {account.username} 的密码,已重置登录状态")
# 更新内存中的账号信息
account.password = new_password
# 更新内存中的账号信息(内存中保存明文,用于自动化登录)
account._password = new_password
account.remember = new_remember
log_to_client(f"账号 {account.username} 信息已更新,登录状态已重置", user_id)