From de51e1b7c7129f570ebc3d581abe783d6d2b0f8f Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Thu, 11 Dec 2025 19:14:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=E9=A1=B9=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=BC=8F=E6=B4=9E=E5=92=8CBug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api_browser.py | 8 +- app.py | 119 +++++++++++++++------------ crypto_utils.py | 169 +++++++++++++++++++++++++++++++++++++++ database.py | 58 +++++++++++--- playwright_automation.py | 4 +- templates/login.html | 5 +- 6 files changed, 293 insertions(+), 70 deletions(-) create mode 100644 crypto_utils.py diff --git a/api_browser.py b/api_browser.py index 5dfffcc..fb89df6 100755 --- a/api_browser.py +++ b/api_browser.py @@ -64,12 +64,12 @@ class APIBrowser: import os import json import hashlib - + cookies_dir = '/app/data/cookies' os.makedirs(cookies_dir, exist_ok=True) - - # 用用户名的hash作为文件名 - filename = hashlib.md5(username.encode()).hexdigest() + '.json' + + # 安全修复:使用SHA256代替MD5作为文件名哈希 + filename = hashlib.sha256(username.encode()).hexdigest()[:32] + '.json' cookies_path = os.path.join(cookies_dir, filename) try: diff --git a/app.py b/app.py index c1f888a..eb9a5cc 100755 --- a/app.py +++ b/app.py @@ -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(避免资源竞争) -# ���图信号量将在首次使用时初始化 +# 截图专用信号量:限制同时进行的截图任务数量(避免资源竞争) +# 信号量将在首次使用时初始化,并支持动态更新 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) diff --git a/crypto_utils.py b/crypto_utils.py new file mode 100644 index 0000000..8ebd6f8 --- /dev/null +++ b/crypto_utils.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +加密工具模块 +用于加密存储敏感信息(如第三方账号密码) +使用Fernet对称加密 +""" + +import os +import base64 +from pathlib import Path +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + + +# 加密密钥文件路径 +ENCRYPTION_KEY_FILE = 'data/encryption_key.bin' +ENCRYPTION_SALT_FILE = 'data/encryption_salt.bin' + + +def _get_or_create_salt(): + """获取或创建盐值""" + salt_path = Path(ENCRYPTION_KEY_FILE).parent / 'encryption_salt.bin' + if salt_path.exists(): + with open(salt_path, 'rb') as f: + return f.read() + + # 生成新的盐值 + salt = os.urandom(16) + os.makedirs(salt_path.parent, exist_ok=True) + with open(salt_path, 'wb') as f: + f.write(salt) + return salt + + +def _derive_key(password: bytes, salt: bytes) -> bytes: + """从密码派生加密密钥""" + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=480000, # OWASP推荐的迭代次数 + ) + return base64.urlsafe_b64encode(kdf.derive(password)) + + +def get_encryption_key(): + """获取加密密钥(优先环境变量,否则从文件读取或生成)""" + # 优先从环境变量读取 + env_key = os.environ.get('ENCRYPTION_KEY') + if env_key: + # 使用环境变量中的密钥派生Fernet密钥 + salt = _get_or_create_salt() + return _derive_key(env_key.encode(), salt) + + # 从文件读取 + key_path = Path(ENCRYPTION_KEY_FILE) + if key_path.exists(): + with open(key_path, 'rb') as f: + return f.read() + + # 生成新的密钥 + key = Fernet.generate_key() + os.makedirs(key_path.parent, exist_ok=True) + with open(key_path, 'wb') as f: + f.write(key) + print(f"[安全] 已生成新的加密密钥并保存到 {ENCRYPTION_KEY_FILE}") + return key + + +# 全局Fernet实例 +_fernet = None + + +def _get_fernet(): + """获取Fernet加密器(懒加载)""" + global _fernet + if _fernet is None: + key = get_encryption_key() + _fernet = Fernet(key) + return _fernet + + +def encrypt_password(plain_password: str) -> str: + """ + 加密密码 + + Args: + plain_password: 明文密码 + + Returns: + str: 加密后的密码(base64编码) + """ + if not plain_password: + return '' + + fernet = _get_fernet() + encrypted = fernet.encrypt(plain_password.encode('utf-8')) + return encrypted.decode('utf-8') + + +def decrypt_password(encrypted_password: str) -> str: + """ + 解密密码 + + Args: + encrypted_password: 加密的密码 + + Returns: + str: 明文密码 + """ + if not encrypted_password: + return '' + + try: + fernet = _get_fernet() + decrypted = fernet.decrypt(encrypted_password.encode('utf-8')) + return decrypted.decode('utf-8') + except Exception as e: + # 解密失败,可能是旧的明文密码 + print(f"[警告] 密码解密失败,可能是未加密的旧数据: {e}") + return encrypted_password + + +def is_encrypted(password: str) -> bool: + """ + 检查密码是否已加密 + Fernet加密的数据以'gAAAAA'开头 + + Args: + password: 要检查的密码 + + Returns: + bool: 是否已加密 + """ + if not password: + return False + # Fernet加密的数据是base64编码,以'gAAAAA'开头 + return password.startswith('gAAAAA') + + +def migrate_password(password: str) -> str: + """ + 迁移密码:如果是明文则加密,如果已加密则保持不变 + + Args: + password: 密码(可能是明文或已加密) + + Returns: + str: 加密后的密码 + """ + if is_encrypted(password): + return password + return encrypt_password(password) + + +if __name__ == '__main__': + # 测试加密解密 + test_password = "test_password_123" + + print(f"原始密码: {test_password}") + encrypted = encrypt_password(test_password) + print(f"加密后: {encrypted}") + decrypted = decrypt_password(encrypted) + print(f"解密后: {decrypted}") + print(f"加密解密成功: {test_password == decrypted}") + print(f"是否已加密: {is_encrypted(encrypted)}") + print(f"明文是否加密: {is_encrypted(test_password)}") diff --git a/database.py b/database.py index cf68402..908484e 100755 --- a/database.py +++ b/database.py @@ -26,6 +26,7 @@ from password_utils import ( verify_password_sha256 ) from app_config import get_config +from crypto_utils import encrypt_password, decrypt_password, migrate_password # 获取配置 config = get_config() @@ -473,7 +474,14 @@ def _migrate_to_v5(conn): # ==================== 管理员相关 ==================== def ensure_default_admin(): - """确保存在默认管理员账号 admin/admin""" + """确保存在默认管理员账号 + + 安全修复:使用随机生成的强密码代替弱密码'admin' + 首次运行时会将密码打印到控制台,请及时修改 + """ + import secrets + import string + with db_pool.get_db() as conn: cursor = conn.cursor() @@ -482,14 +490,22 @@ def ensure_default_admin(): result = cursor.fetchone() if result['count'] == 0: - # 创建默认管理员 admin/admin - default_password_hash = hash_password_bcrypt('admin') + # 安全修复:生成随机强密码(12位,包含大小写字母和数字) + alphabet = string.ascii_letters + string.digits + random_password = ''.join(secrets.choice(alphabet) for _ in range(12)) + + default_password_hash = hash_password_bcrypt(random_password) cursor.execute( 'INSERT INTO admins (username, password_hash) VALUES (?, ?)', ('admin', default_password_hash) ) conn.commit() - print("✓ 已创建默认管理员账号 (admin/admin)") + print("=" * 60) + print("安全提醒:已创建默认管理员账号") + print(f"用户名: admin") + print(f"密码: {random_password}") + print("请立即登录后修改密码!") + print("=" * 60) return True return False @@ -812,32 +828,45 @@ def delete_user(user_id): # ==================== 账号相关 ==================== def create_account(user_id, account_id, username, password, remember=True, remark=''): - """创建账号""" + """创建账号(密码加密存储)""" with db_pool.get_db() as conn: cursor = conn.cursor() + # 安全修复:加密存储第三方账号密码 + encrypted_password = encrypt_password(password) cursor.execute(''' INSERT INTO accounts (id, user_id, username, password, remember, remark) VALUES (?, ?, ?, ?, ?, ?) - ''', (account_id, user_id, username, password, 1 if remember else 0, remark)) + ''', (account_id, user_id, username, encrypted_password, 1 if remember else 0, remark)) conn.commit() return cursor.lastrowid def get_user_accounts(user_id): - """获取用户的所有账号""" + """获取用户的所有账号(自动解密密码)""" with db_pool.get_db() as conn: cursor = conn.cursor() cursor.execute('SELECT * FROM accounts WHERE user_id = ? ORDER BY created_at DESC', (user_id,)) - return [dict(row) for row in cursor.fetchall()] + accounts = [] + for row in cursor.fetchall(): + account = dict(row) + # 安全修复:解密第三方账号密码(兼容旧数据) + account['password'] = decrypt_password(account.get('password', '')) + accounts.append(account) + return accounts def get_account(account_id): - """获取单个账号""" + """获取单个账号(自动解密密码)""" with db_pool.get_db() as conn: cursor = conn.cursor() cursor.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)) row = cursor.fetchone() - return dict(row) if row else None + if row: + account = dict(row) + # 安全修复:解密第三方账号密码(兼容旧数据) + account['password'] = decrypt_password(account.get('password', '')) + return account + return None def update_account_remark(account_id, remark): @@ -1483,17 +1512,22 @@ def get_feedback_by_id(feedback_id): def reply_feedback(feedback_id, admin_reply): - """管理员回复反馈""" + """管理员回复反馈(带XSS防护)""" + from app_security import escape_html + with db_pool.get_db() as conn: cursor = conn.cursor() cst_tz = pytz.timezone("Asia/Shanghai") cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S") + # 安全修复:转义管理员回复,防止存储型XSS攻击 + safe_reply = escape_html(admin_reply) if admin_reply else '' + cursor.execute(''' UPDATE bug_feedbacks SET admin_reply = ?, status = 'replied', replied_at = ? WHERE id = ? - ''', (admin_reply, cst_time, feedback_id)) + ''', (safe_reply, cst_time, feedback_id)) conn.commit() return cursor.rowcount > 0 diff --git a/playwright_automation.py b/playwright_automation.py index 52ec62d..3927ba0 100755 --- a/playwright_automation.py +++ b/playwright_automation.py @@ -166,9 +166,9 @@ class PlaywrightAutomation: """获取cookies文件路径""" import os os.makedirs(self.COOKIES_DIR, exist_ok=True) - # 用用户名的hash作为文件名,避免特殊字符问题 + # 安全修复:使用SHA256代替MD5作为文件名哈希 import hashlib - filename = hashlib.md5(username.encode()).hexdigest() + '.json' + filename = hashlib.sha256(username.encode()).hexdigest()[:32] + '.json' return os.path.join(self.COOKIES_DIR, filename) def save_cookies(self, username: str): diff --git a/templates/login.html b/templates/login.html index 0638c10..00c4b55 100644 --- a/templates/login.html +++ b/templates/login.html @@ -204,7 +204,7 @@
-
+