修复多项安全漏洞
安全修复清单: 1. 验证码改为图片方式返回,防止明文泄露 2. CORS配置从环境变量读取,不再使用通配符"*" 3. VIP API添加@admin_required装饰器,统一认证 4. 用户登录统一错误消息,防止用户枚举 5. IP限流不再信任X-Forwarded-For头,防止伪造绕过 6. 密码强度要求提升(8位+字母+数字) 7. 日志不���记录完整session/cookie内容,防止敏感信息泄露 8. XSS防护:日志输出和Bug反馈内容转义HTML 9. SQL注入防护:LIKE查询参数转义 10. 路径遍历防护:截图目录白名单验证 11. 验证码重放防护:验证前删除验证码 12. 数据库连接池健康检查 13. 正则DoS防护:限制数字匹配长度 14. Account类密码私有化,__repr__不暴露密码 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
180
app.py
180
app.py
@@ -60,9 +60,17 @@ app.config.from_object(config)
|
||||
# 确保SECRET_KEY已设置
|
||||
if not app.config.get('SECRET_KEY'):
|
||||
raise RuntimeError("SECRET_KEY未配置,请检查app_config.py")
|
||||
# 安全修复:从环境变量获取允许的CORS源,不再使用"*"
|
||||
cors_origins = os.environ.get('CORS_ALLOWED_ORIGINS', '').strip()
|
||||
if cors_origins:
|
||||
cors_allowed = [origin.strip() for origin in cors_origins.split(',') if origin.strip()]
|
||||
else:
|
||||
# 默认只允许同源,生产环<E4BAA7><E78EAF>应该明确配置
|
||||
cors_allowed = []
|
||||
|
||||
socketio = SocketIO(
|
||||
app,
|
||||
cors_allowed_origins="*",
|
||||
cors_allowed_origins=cors_allowed if cors_allowed else None, # None表示只允许同源
|
||||
async_mode='threading', # 明确指定async模式
|
||||
ping_timeout=60, # ping超时60秒
|
||||
ping_interval=25, # 每25秒ping一次
|
||||
@@ -169,12 +177,22 @@ class Admin(UserMixin):
|
||||
|
||||
|
||||
class Account:
|
||||
"""账号类"""
|
||||
"""账号类
|
||||
|
||||
安全注意事项:
|
||||
- 密码以私有属性存储,避免意外泄露
|
||||
- __repr__不包含密码,防止日志意外记录
|
||||
- 生产环境应考虑使用加密存储(如Fernet加密)
|
||||
"""
|
||||
__slots__ = ['id', 'user_id', 'username', '_password', 'remember', 'remark',
|
||||
'status', 'is_running', 'should_stop', 'total_items',
|
||||
'total_attachments', 'automation', 'last_browse_type', 'proxy_config']
|
||||
|
||||
def __init__(self, account_id, user_id, username, password, remember=True, remark=''):
|
||||
self.id = account_id
|
||||
self.user_id = user_id
|
||||
self.username = username
|
||||
self.password = password
|
||||
self._password = password # 安全修复:使用私有属性存储密码
|
||||
self.remember = remember
|
||||
self.remark = remark
|
||||
self.status = "未开始"
|
||||
@@ -186,6 +204,15 @@ class Account:
|
||||
self.last_browse_type = "注册前未读"
|
||||
self.proxy_config = None # 保存代理配置,浏览和截图共用
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
"""获取密码(仅供自动化登录使用)"""
|
||||
return self._password
|
||||
|
||||
def __repr__(self):
|
||||
"""安全的字符串表示,不包含密码"""
|
||||
return f"Account(id={self.id}, username={self.username}, status={self.status})"
|
||||
|
||||
def to_dict(self):
|
||||
result = {
|
||||
"id": self.id,
|
||||
@@ -307,8 +334,8 @@ def admin_required(f):
|
||||
"""管理员权限装饰器"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
logger.debug(f"[admin_required] Session内容: {dict(session)}")
|
||||
logger.debug(f"[admin_required] Cookies: {request.cookies}")
|
||||
# 安全修复:不再记录完整的session和cookies内容,防止敏感信息泄露
|
||||
logger.debug(f"[admin_required] 检查会话,admin_id存在: {'admin_id' in session}")
|
||||
if 'admin_id' not in session:
|
||||
logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
|
||||
return jsonify({"error": "需要管理员权限"}), 403
|
||||
@@ -319,10 +346,12 @@ def admin_required(f):
|
||||
|
||||
def log_to_client(message, user_id=None, account_id=None):
|
||||
"""发送日志到Web客户端(用户隔离)"""
|
||||
from app_security import escape_html
|
||||
timestamp = get_beijing_now().strftime('%H:%M:%S')
|
||||
# 安全修复:转义HTML特殊字符,防止XSS攻击
|
||||
log_data = {
|
||||
'timestamp': timestamp,
|
||||
'message': message,
|
||||
'message': escape_html(str(message)) if message else '',
|
||||
'account_id': account_id
|
||||
}
|
||||
|
||||
@@ -579,10 +608,17 @@ def check_ip_rate_limit(ip_address):
|
||||
"""检查IP是否被限流"""
|
||||
current_time = time.time()
|
||||
|
||||
# 清理过期的IP记录
|
||||
expired_ips = [ip for ip, data in ip_rate_limit.items()
|
||||
if data.get("lock_until", 0) < current_time and
|
||||
current_time - data.get("first_attempt", current_time) > 3600]
|
||||
# 安全修复:修正过期IP清理逻辑
|
||||
# 原问题:first_attempt不存在时默认使用current_time,导致永远不会被清理
|
||||
expired_ips = []
|
||||
for ip, data in ip_rate_limit.items():
|
||||
lock_expired = data.get("lock_until", 0) < current_time
|
||||
first_attempt = data.get("first_attempt")
|
||||
# 修复:如果first_attempt不存在或超过1小时,视为过期
|
||||
attempt_expired = first_attempt is None or (current_time - first_attempt > 3600)
|
||||
if lock_expired and attempt_expired:
|
||||
expired_ips.append(ip)
|
||||
|
||||
for ip in expired_ips:
|
||||
del ip_rate_limit[ip]
|
||||
|
||||
@@ -596,7 +632,8 @@ def check_ip_rate_limit(ip_address):
|
||||
return False, "IP已被锁定,请{}分钟后再试".format(remaining_time // 60 + 1)
|
||||
|
||||
# 如果超过1小时,重置计数
|
||||
if current_time - ip_data.get("first_attempt", current_time) > 3600:
|
||||
first_attempt = ip_data.get("first_attempt")
|
||||
if first_attempt is None or current_time - first_attempt > 3600:
|
||||
ip_rate_limit[ip_address] = {
|
||||
"attempts": 0,
|
||||
"first_attempt": current_time
|
||||
@@ -627,8 +664,14 @@ def record_failed_captcha(ip_address):
|
||||
|
||||
@app.route("/api/generate_captcha", methods=["POST"])
|
||||
def generate_captcha():
|
||||
"""生成4位数字验证码"""
|
||||
"""生成4位数字验证码图片
|
||||
|
||||
安全修复:验证码不再以明文返回,而是生成base64图片
|
||||
"""
|
||||
import uuid
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# 生成4位随机数字
|
||||
@@ -646,7 +689,60 @@ def generate_captcha():
|
||||
for k in expired_keys:
|
||||
del captcha_storage[k]
|
||||
|
||||
return jsonify({"session_id": session_id, "captcha": code})
|
||||
# 生成验证码图片
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
|
||||
# 创建图片
|
||||
width, height = 120, 40
|
||||
image = Image.new('RGB', (width, height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 添加干扰线
|
||||
for _ in range(5):
|
||||
x1 = random.randint(0, width)
|
||||
y1 = random.randint(0, height)
|
||||
x2 = random.randint(0, width)
|
||||
y2 = random.randint(0, height)
|
||||
draw.line([(x1, y1), (x2, y2)], fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
|
||||
|
||||
# 添加干扰点
|
||||
for _ in range(50):
|
||||
x = random.randint(0, width)
|
||||
y = random.randint(0, height)
|
||||
draw.point((x, y), fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
|
||||
|
||||
# 绘制验证码文字
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 28)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for i, char in enumerate(code):
|
||||
x = 10 + i * 25 + random.randint(-3, 3)
|
||||
y = random.randint(2, 8)
|
||||
color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
|
||||
draw.text((x, y), char, font=font, fill=color)
|
||||
|
||||
# 转换为base64
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format='PNG')
|
||||
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return jsonify({
|
||||
"session_id": session_id,
|
||||
"captcha_image": f"data:image/png;base64,{img_base64}"
|
||||
})
|
||||
except ImportError:
|
||||
# 如果没有PIL,退回到简单文本(但添加混淆)
|
||||
# 安全警告:生产环境应安装PIL
|
||||
logger.warning("PIL未安装,验证码安全性降低")
|
||||
# 不直接返回验证码,返回混淆后的提示
|
||||
return jsonify({
|
||||
"session_id": session_id,
|
||||
"captcha_hint": "验证码图片生成失败,请联系管理员"
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/login', methods=['POST'])
|
||||
@@ -666,16 +762,12 @@ def login():
|
||||
if not success:
|
||||
return jsonify({"error": message}), 400
|
||||
|
||||
# 先检查用户是否存在
|
||||
user_exists = database.get_user_by_username(username)
|
||||
if not user_exists:
|
||||
return jsonify({"error": "账号未注册", "need_captcha": True}), 401
|
||||
|
||||
# 检查密码是否正确
|
||||
# 安全修复:使用通用错误消息防止用户枚举
|
||||
# 不再分开检查用户是否存在和密码是否正确
|
||||
user = database.verify_user(username, password)
|
||||
if not user:
|
||||
# 密码错误
|
||||
return jsonify({"error": "密码错误", "need_captcha": True}), 401
|
||||
# 返回通用错误消息,不透露账号是否存在
|
||||
return jsonify({"error": "用户名或密码错误", "need_captcha": True}), 401
|
||||
|
||||
# 检查审核状态
|
||||
if user['status'] != 'approved':
|
||||
@@ -699,8 +791,13 @@ def logout():
|
||||
# ==================== 管理员认证API ====================
|
||||
|
||||
@app.route('/yuyx/api/debug-config', methods=['GET'])
|
||||
@admin_required # 安全修复:添加管理员认证
|
||||
def debug_config():
|
||||
"""调试配置信息"""
|
||||
"""调试配置信息(仅管理员可访问,生产环境应禁用)"""
|
||||
# 安全修复:生产环境禁用调试端点
|
||||
if not app.debug:
|
||||
return jsonify({"error": "调试端点已在生产环境禁用"}), 403
|
||||
|
||||
return jsonify({
|
||||
"secret_key_set": bool(app.secret_key),
|
||||
"secret_key_length": len(app.secret_key) if app.secret_key else 0,
|
||||
@@ -711,7 +808,8 @@ def debug_config():
|
||||
"SESSION_COOKIE_SAMESITE": app.config.get('SESSION_COOKIE_SAMESITE'),
|
||||
"PERMANENT_SESSION_LIFETIME": str(app.config.get('PERMANENT_SESSION_LIFETIME')),
|
||||
},
|
||||
"current_session": dict(session),
|
||||
# 安全修复:移除敏感的session内容,只显示有无
|
||||
"has_session": bool(session),
|
||||
"cookies_received": list(request.cookies.keys())
|
||||
})
|
||||
|
||||
@@ -751,9 +849,8 @@ def admin_login():
|
||||
session.permanent = True # 设置为永久会话(使用PERMANENT_SESSION_LIFETIME配置)
|
||||
session.modified = True # 强制标记session为已修改,确保保存
|
||||
|
||||
logger.info(f"[admin_login] 管理员 {username} 登录成功, session已设置: admin_id={admin['id']}")
|
||||
logger.debug(f"[admin_login] Session内容: {dict(session)}")
|
||||
logger.debug(f"[admin_login] Cookie将被设置: name={app.config.get('SESSION_COOKIE_NAME', 'session')}")
|
||||
logger.info(f"[admin_login] 管理员 {username} 登录成功")
|
||||
# 安全修复:不再记录完整session内容
|
||||
|
||||
# 根据请求类型返回不同响应
|
||||
if request.is_json:
|
||||
@@ -1234,12 +1331,14 @@ def get_accounts():
|
||||
user_id = current_user.id
|
||||
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
|
||||
# 如果user_accounts中没有数据或者请求刷新,则从数据库加载
|
||||
if user_id not in user_accounts or len(user_accounts.get(user_id, {})) == 0 or refresh:
|
||||
load_user_accounts(user_id)
|
||||
# 安全修复:使用锁保护检查和加载操作,防止竞态条件
|
||||
with user_accounts_lock:
|
||||
# 如果user_accounts中没有数据或者请求刷新,则从数据库加载
|
||||
if user_id not in user_accounts or len(user_accounts.get(user_id, {})) == 0 or refresh:
|
||||
load_user_accounts(user_id)
|
||||
|
||||
accounts = user_accounts.get(user_id, {})
|
||||
return jsonify([acc.to_dict() for acc in accounts.values()])
|
||||
accounts = user_accounts.get(user_id, {})
|
||||
return jsonify([acc.to_dict() for acc in accounts.values()])
|
||||
|
||||
|
||||
@app.route('/api/accounts', methods=['POST'])
|
||||
@@ -2252,20 +2351,17 @@ def serve_static(filename):
|
||||
# ==================== 管理员VIP管理API ====================
|
||||
|
||||
@app.route('/yuyx/api/vip/config', methods=['GET'])
|
||||
@admin_required # 安全修复:使用统一的装饰器
|
||||
def get_vip_config_api():
|
||||
"""获取VIP配置"""
|
||||
if 'admin_id' not in session:
|
||||
return jsonify({"error": "需要管理员权限"}), 403
|
||||
config = database.get_vip_config()
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@app.route('/yuyx/api/vip/config', methods=['POST'])
|
||||
@admin_required # 安全修复:使用统一的装饰器
|
||||
def set_vip_config_api():
|
||||
"""设置默认VIP天数"""
|
||||
if 'admin_id' not in session:
|
||||
return jsonify({"error": "需要管理员权限"}), 403
|
||||
|
||||
data = request.json
|
||||
days = data.get('default_vip_days', 0)
|
||||
|
||||
@@ -2277,11 +2373,9 @@ def set_vip_config_api():
|
||||
|
||||
|
||||
@app.route('/yuyx/api/users/<int:user_id>/vip', methods=['POST'])
|
||||
@admin_required # 安全修复:使用统一的装饰器
|
||||
def set_user_vip_api(user_id):
|
||||
"""设置用户VIP"""
|
||||
if 'admin_id' not in session:
|
||||
return jsonify({"error": "需要管理员权限"}), 403
|
||||
|
||||
data = request.json
|
||||
days = data.get('days', 30)
|
||||
|
||||
@@ -2297,22 +2391,18 @@ def set_user_vip_api(user_id):
|
||||
|
||||
|
||||
@app.route('/yuyx/api/users/<int:user_id>/vip', methods=['DELETE'])
|
||||
@admin_required # 安全修复:使用统一的装饰器
|
||||
def remove_user_vip_api(user_id):
|
||||
"""移除用户VIP"""
|
||||
if 'admin_id' not in session:
|
||||
return jsonify({"error": "需要管理员权限"}), 403
|
||||
|
||||
if database.remove_user_vip(user_id):
|
||||
return jsonify({"message": "VIP已移除"})
|
||||
return jsonify({"error": "移除失败"}), 400
|
||||
|
||||
|
||||
@app.route('/yuyx/api/users/<int:user_id>/vip', methods=['GET'])
|
||||
@admin_required # 安全修复:使用统一的装饰器
|
||||
def get_user_vip_info_api(user_id):
|
||||
"""获取用户VIP信息(管理员)"""
|
||||
if 'admin_id' not in session:
|
||||
return jsonify({"error": "需要管理员权限"}), 403
|
||||
|
||||
vip_info = database.get_user_vip_info(user_id)
|
||||
return jsonify(vip_info)
|
||||
|
||||
|
||||
@@ -240,12 +240,15 @@ def validate_username(username):
|
||||
return True, None
|
||||
|
||||
|
||||
def validate_password(password):
|
||||
def validate_password(password, require_complexity=True):
|
||||
"""
|
||||
验证密码强度
|
||||
|
||||
安全修复:增强密码强度要求
|
||||
|
||||
Args:
|
||||
password: 密码
|
||||
require_complexity: 是否要求复杂度(默认True)
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, error_message)
|
||||
@@ -253,19 +256,19 @@ def validate_password(password):
|
||||
if not password:
|
||||
return False, "密码不能为空"
|
||||
|
||||
if len(password) < 6:
|
||||
return False, "密码长度不能少于6个字符"
|
||||
if len(password) < 8: # 安全修复:最少8位
|
||||
return False, "密码长度不能少于8个字符"
|
||||
|
||||
if len(password) > 128:
|
||||
return False, "密码长度不能超过128个字符"
|
||||
|
||||
# 可选:强制密码复杂度
|
||||
# has_upper = bool(re.search(r'[A-Z]', password))
|
||||
# has_lower = bool(re.search(r'[a-z]', password))
|
||||
# has_digit = bool(re.search(r'\d', password))
|
||||
#
|
||||
# if not (has_upper and has_lower and has_digit):
|
||||
# return False, "密码必须包含大写字母、小写字母和数字"
|
||||
# 安全修复:启用密码复杂度要求
|
||||
if require_complexity:
|
||||
has_letter = bool(re.search(r'[a-zA-Z]', password))
|
||||
has_digit = bool(re.search(r'\d', password))
|
||||
|
||||
if not (has_letter and has_digit):
|
||||
return False, "密码必须包含字母和数字"
|
||||
|
||||
return True, None
|
||||
|
||||
@@ -392,20 +395,28 @@ def check_security_config():
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
|
||||
def get_client_ip():
|
||||
def get_client_ip(trust_proxy=False):
|
||||
"""
|
||||
获取客户端真实IP地址
|
||||
|
||||
安全修复:默认不信任代理头,防止IP伪造绕过限流
|
||||
|
||||
Args:
|
||||
trust_proxy: 是否信任代理头(仅在已知可信代理后设置为True)
|
||||
|
||||
Returns:
|
||||
str: IP地址
|
||||
"""
|
||||
# 检查代理头
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
elif request.headers.get('X-Real-IP'):
|
||||
return request.headers.get('X-Real-IP')
|
||||
else:
|
||||
return request.remote_addr
|
||||
# 安全说明:X-Forwarded-For 可被伪造
|
||||
# 仅在确认请求来自可信代理时才使用代理头
|
||||
if trust_proxy:
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
elif request.headers.get('X-Real-IP'):
|
||||
return request.headers.get('X-Real-IP')
|
||||
|
||||
# 默认使用remote_addr(更安全但可能是代理IP)
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
49
app_utils.py
49
app_utils.py
@@ -282,7 +282,11 @@ def check_user_ownership(user_id: int, resource_type: str,
|
||||
|
||||
def verify_and_consume_captcha(session_id: str, code: str, captcha_storage: dict, max_attempts: int = 5) -> Tuple[bool, str]:
|
||||
"""
|
||||
验证并消费验证码
|
||||
验证并消费验证码(安全增强版)
|
||||
|
||||
安全特性:
|
||||
- 先删除验证码再验证,防止重放攻击
|
||||
- 异常情况下也确保验证码被删除
|
||||
|
||||
Args:
|
||||
session_id: 验证码会话ID
|
||||
@@ -307,30 +311,37 @@ def verify_and_consume_captcha(session_id: str, code: str, captcha_storage: dict
|
||||
"""
|
||||
import time
|
||||
|
||||
# 安全修复:先取出并删除验证码,无论验证是否成功都不能重用
|
||||
captcha_data = captcha_storage.pop(session_id, None)
|
||||
|
||||
# 检查验证码是否存在
|
||||
if session_id not in captcha_storage:
|
||||
if captcha_data is None:
|
||||
return False, "验证码已过期或不存在,请重新获取"
|
||||
|
||||
captcha_data = captcha_storage[session_id]
|
||||
try:
|
||||
# 检查过期时间
|
||||
if captcha_data["expire_time"] < time.time():
|
||||
return False, "验证码已过期,请重新获取"
|
||||
|
||||
# 检查过期时间
|
||||
if captcha_data["expire_time"] < time.time():
|
||||
del captcha_storage[session_id]
|
||||
return False, "验证码已过期,请重新获取"
|
||||
# 检查尝试次数
|
||||
if captcha_data.get("failed_attempts", 0) >= max_attempts:
|
||||
return False, f"验证码错误次数过多({max_attempts}次),请重新获取"
|
||||
|
||||
# 检查尝试次数
|
||||
if captcha_data.get("failed_attempts", 0) >= max_attempts:
|
||||
del captcha_storage[session_id]
|
||||
return False, f"验证码错误次数过多({max_attempts}次),请重新获取"
|
||||
# 验证代码(不区分大小写)
|
||||
if captcha_data["code"].lower() != code.lower():
|
||||
# 验证失败,增加失败计数后放回(允许继续尝试)
|
||||
captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
|
||||
# 只有未超过最大尝试次数才放回
|
||||
if captcha_data["failed_attempts"] < max_attempts:
|
||||
captcha_storage[session_id] = captcha_data
|
||||
return False, "验证码错误"
|
||||
|
||||
# 验证代码(不区分大小写)
|
||||
if captcha_data["code"].lower() != code.lower():
|
||||
captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
|
||||
return False, "验证码错误"
|
||||
|
||||
# 验证成功,删除验证码(防止重复使用)
|
||||
del captcha_storage[session_id]
|
||||
return True, "验证成功"
|
||||
# 验证成功,验证码已被删除,不会被重用
|
||||
return True, "验证成功"
|
||||
except Exception as e:
|
||||
# 异常情况下确保验证码不会被重用(已在函数开头删除)
|
||||
logger.error(f"验证码验证异常: {e}")
|
||||
return False, "验证码验证失败,请重新获取"
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
29
database.py
29
database.py
@@ -630,7 +630,10 @@ def remove_user_vip(user_id):
|
||||
|
||||
|
||||
def is_user_vip(user_id):
|
||||
"""检查用户是否是VIP"""
|
||||
"""检查用户是否是VIP
|
||||
|
||||
注意:数据库中存储的时间统一使用CST(Asia/Shanghai)时区
|
||||
"""
|
||||
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||
user = get_user_by_id(user_id)
|
||||
|
||||
@@ -638,9 +641,12 @@ def is_user_vip(user_id):
|
||||
return False
|
||||
|
||||
try:
|
||||
# 时区处理说明:数据库存储的是CST时间字符串
|
||||
# 如果将来改为UTC存储,需要修改此处逻辑
|
||||
expire_time_naive = datetime.strptime(user['vip_expire_time'], '%Y-%m-%d %H:%M:%S')
|
||||
expire_time = cst_tz.localize(expire_time_naive)
|
||||
return datetime.now(cst_tz) < expire_time
|
||||
now = datetime.now(cst_tz)
|
||||
return now < expire_time
|
||||
except (ValueError, AttributeError) as e:
|
||||
print(f"检查VIP状态失败 (user_id={user_id}): {e}")
|
||||
return False
|
||||
@@ -1137,8 +1143,11 @@ def get_task_logs(limit=100, offset=0, date_filter=None, status_filter=None,
|
||||
params.append(user_id_filter)
|
||||
|
||||
if account_filter:
|
||||
where_clauses.append("tl.username LIKE ?")
|
||||
params.append(f"%{account_filter}%")
|
||||
# 转义LIKE中的特殊字符,防止绕过过滤
|
||||
from app_security import sanitize_sql_like_pattern
|
||||
safe_filter = sanitize_sql_like_pattern(account_filter)
|
||||
where_clauses.append("tl.username LIKE ? ESCAPE '\\'")
|
||||
params.append(f"%{safe_filter}%")
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
@@ -1409,16 +1418,24 @@ def clean_old_operation_logs(days=30):
|
||||
# ==================== Bug反馈管理 ====================
|
||||
|
||||
def create_bug_feedback(user_id, username, title, description, contact=''):
|
||||
"""创建Bug反馈"""
|
||||
"""创建Bug反馈(带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_title = escape_html(title) if title else ''
|
||||
safe_description = escape_html(description) if description else ''
|
||||
safe_contact = escape_html(contact) if contact else ''
|
||||
safe_username = escape_html(username) if username else ''
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO bug_feedbacks (user_id, username, title, description, contact, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (user_id, username, title, description, contact, cst_time))
|
||||
''', (user_id, safe_username, safe_title, safe_description, safe_contact, cst_time))
|
||||
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
@@ -65,7 +65,7 @@ class ConnectionPool:
|
||||
|
||||
def return_connection(self, conn):
|
||||
"""
|
||||
归还连接到连接池 [已修复Bug#7]
|
||||
归还连接到连接池 [已修复Bug#7, Bug#11]
|
||||
|
||||
Args:
|
||||
conn: 要归还的连接
|
||||
@@ -76,6 +76,8 @@ class ConnectionPool:
|
||||
try:
|
||||
# 回滚任何未提交的事务
|
||||
conn.rollback()
|
||||
# 安全修复:验证连接是否健康,防止损坏的连接污染连接池
|
||||
conn.execute("SELECT 1")
|
||||
self._pool.put(conn, block=False)
|
||||
except sqlite3.Error as e:
|
||||
# 数据库相关错误,连接可能损坏
|
||||
|
||||
@@ -907,7 +907,8 @@ class PlaywrightAutomation:
|
||||
# 解析"共XXX记录"获取总数
|
||||
if expected_total is None:
|
||||
import re
|
||||
match = re.search(r'共(\d+)记录', page_text)
|
||||
# 安全修复:限制数字匹配长度,防止ReDoS攻击
|
||||
match = re.search(r'共(\d{1,10})记录', page_text)
|
||||
if match:
|
||||
expected_total = int(match.group(1))
|
||||
self.log(f"[总数] 预期浏览 {expected_total} 条内容")
|
||||
|
||||
@@ -19,6 +19,26 @@ def take_screenshot(config):
|
||||
screenshot_path = config['screenshot_path']
|
||||
cookies_file = config.get('cookies_file', '')
|
||||
|
||||
# 安全修复:验证截图路径在允许的目录内,防止路径遍历攻击
|
||||
ALLOWED_SCREENSHOT_DIRS = [
|
||||
'/root/zsglpt/screenshots',
|
||||
'/root/zsglpt/static/screenshots',
|
||||
'/tmp/zsglpt_screenshots'
|
||||
]
|
||||
|
||||
def is_safe_screenshot_path(path):
|
||||
"""验证截图路径是否安全"""
|
||||
abs_path = os.path.abspath(path)
|
||||
return any(abs_path.startswith(os.path.abspath(allowed_dir))
|
||||
for allowed_dir in ALLOWED_SCREENSHOT_DIRS)
|
||||
|
||||
if not is_safe_screenshot_path(screenshot_path):
|
||||
return {
|
||||
'success': False,
|
||||
'message': '非法截图路径',
|
||||
'screenshot_path': ''
|
||||
}
|
||||
|
||||
result = {
|
||||
'success': False,
|
||||
'message': '',
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
<label for="captcha">验证码</label>
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码" style="flex: 1;">
|
||||
<span id="captchaCode" style="font-size: 20px; font-weight: bold; letter-spacing: 5px; color: #f5576c; user-select: none;">----</span>
|
||||
<img id="captchaImage" src="" alt="验证码" style="height: 36px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" onclick="refreshCaptcha()" title="点击刷新">
|
||||
<button type="button" onclick="refreshCaptcha()" style="padding: 8px 15px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,9 +278,9 @@
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.session_id && data.captcha) {
|
||||
if (data.session_id && data.captcha_image) {
|
||||
captchaSession = data.session_id;
|
||||
document.getElementById('captchaCode').textContent = data.captcha;
|
||||
document.getElementById('captchaImage').src = data.captcha_image;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成验证码失败:', error);
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
<label for="captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码">
|
||||
<span id="captchaCode" class="captcha-code">----</span>
|
||||
<img id="captchaImage" src="" alt="验证码" style="height: 36px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" onclick="refreshCaptcha()" title="点击刷新">
|
||||
<button type="button" class="captcha-refresh" onclick="refreshCaptcha()">🔄</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +253,7 @@
|
||||
else { errorDiv.textContent = data.error || '申请失败'; errorDiv.style.display = 'block'; }
|
||||
} catch (error) { errorDiv.textContent = '网络错误'; errorDiv.style.display = 'block'; }
|
||||
}
|
||||
async function generateCaptcha() { try { const response = await fetch('/api/generate_captcha', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (data.session_id && data.captcha) { captchaSession = data.session_id; document.getElementById('captchaCode').textContent = data.captcha; } } catch (error) { console.error('生成验证码失败:', error); } }
|
||||
async function generateCaptcha() { try { const response = await fetch('/api/generate_captcha', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (data.session_id && data.captcha_image) { captchaSession = data.session_id; document.getElementById('captchaImage').src = data.captcha_image; } } catch (error) { console.error('生成验证码失败:', error); } }
|
||||
async function refreshCaptcha() { await generateCaptcha(); document.getElementById('captcha').value = ''; }
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeForgotPassword(); });
|
||||
</script>
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
<label for="captcha">验证码</label>
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<input type="text" id="captcha" placeholder="请输入验证码" required style="flex: 1;">
|
||||
<span id="captchaCode" style="font-size: 20px; font-weight: bold; letter-spacing: 5px; color: #4CAF50;">----</span>
|
||||
<img id="captchaImage" src="" alt="验证码" style="height: 40px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" onclick="refreshCaptcha()" title="点击刷新">
|
||||
<button type="button" onclick="refreshCaptcha()" style="padding: 8px 15px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,7 +263,10 @@
|
||||
async function generateCaptcha() {
|
||||
const resp = await fetch('/api/generate_captcha', {method: 'POST', headers: {'Content-Type': 'application/json'}});
|
||||
const data = await resp.json();
|
||||
if (data.session_id && data.captcha) { captchaSession = data.session_id; document.getElementById('captchaCode').textContent = data.captcha; }
|
||||
if (data.session_id && data.captcha_image) {
|
||||
captchaSession = data.session_id;
|
||||
document.getElementById('captchaImage').src = data.captcha_image;
|
||||
}
|
||||
}
|
||||
async function refreshCaptcha() { await generateCaptcha(); document.getElementById('captcha').value = ''; }
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user