安全修复:加固CSRF与凭证保护并修复越权风险
This commit is contained in:
@@ -355,9 +355,6 @@ def admin_logout():
|
||||
session.pop("admin_id", None)
|
||||
session.pop("admin_username", None)
|
||||
session.pop("admin_reauth_until", None)
|
||||
session.pop("_user_id", None)
|
||||
session.pop("_fresh", None)
|
||||
session.pop("_id", None)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ def get_email_settings_api():
|
||||
return jsonify(settings)
|
||||
except Exception as e:
|
||||
logger.error(f"获取邮件设置失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "获取邮件设置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/email/settings", methods=["POST"])
|
||||
@@ -48,7 +48,7 @@ def update_email_settings_api():
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"更新邮件设置失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "更新邮件设置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/smtp/configs", methods=["GET"])
|
||||
@@ -60,7 +60,7 @@ def get_smtp_configs_api():
|
||||
return jsonify(configs)
|
||||
except Exception as e:
|
||||
logger.error(f"获取SMTP配置失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "获取SMTP配置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/smtp/configs", methods=["POST"])
|
||||
@@ -78,7 +78,7 @@ def create_smtp_config_api():
|
||||
return jsonify({"success": True, "id": config_id})
|
||||
except Exception as e:
|
||||
logger.error(f"创建SMTP配置失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "创建SMTP配置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/smtp/configs/<int:config_id>", methods=["GET"])
|
||||
@@ -92,7 +92,7 @@ def get_smtp_config_api(config_id):
|
||||
return jsonify(config_data)
|
||||
except Exception as e:
|
||||
logger.error(f"获取SMTP配置失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "获取SMTP配置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/smtp/configs/<int:config_id>", methods=["PUT"])
|
||||
@@ -106,7 +106,7 @@ def update_smtp_config_api(config_id):
|
||||
return jsonify({"error": "更新失败"}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"更新SMTP配置失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "更新SMTP配置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/smtp/configs/<int:config_id>", methods=["DELETE"])
|
||||
@@ -119,7 +119,7 @@ def delete_smtp_config_api(config_id):
|
||||
return jsonify({"error": "删除失败"}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"删除SMTP配置失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "删除SMTP配置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/smtp/configs/<int:config_id>/test", methods=["POST"])
|
||||
@@ -140,7 +140,7 @@ def test_smtp_config_api(config_id):
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"测试SMTP配置失败: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
return jsonify({"success": False, "error": "测试SMTP配置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/smtp/configs/<int:config_id>/primary", methods=["POST"])
|
||||
@@ -153,7 +153,7 @@ def set_primary_smtp_config_api(config_id):
|
||||
return jsonify({"error": "设置失败"}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"设置主SMTP配置失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "设置主SMTP配置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/smtp/configs/primary/clear", methods=["POST"])
|
||||
@@ -165,7 +165,7 @@ def clear_primary_smtp_config_api():
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"取消主SMTP配置失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "取消主SMTP配置失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/email/stats", methods=["GET"])
|
||||
@@ -177,7 +177,7 @@ def get_email_stats_api():
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"获取邮件统计失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "获取邮件统计失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/email/logs", methods=["GET"])
|
||||
@@ -195,7 +195,7 @@ def get_email_logs_api():
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"获取邮件日志失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "获取邮件日志失败"}), 500
|
||||
|
||||
|
||||
@admin_api_bp.route("/email/logs/cleanup", methods=["POST"])
|
||||
@@ -211,4 +211,4 @@ def cleanup_email_logs_api():
|
||||
return jsonify({"success": True, "deleted": deleted})
|
||||
except Exception as e:
|
||||
logger.error(f"清理邮件日志失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "清理邮件日志失败"}), 500
|
||||
|
||||
@@ -80,7 +80,7 @@ def update_system_config_api():
|
||||
if schedule_time is not None:
|
||||
import re
|
||||
|
||||
if not re.match(r"^([01]\\d|2[0-3]):([0-5]\\d)$", schedule_time):
|
||||
if not re.match(r"^([01]\d|2[0-3]):([0-5]\d)$", schedule_time):
|
||||
return jsonify({"error": "时间格式错误,应为 HH:MM"}), 400
|
||||
|
||||
if schedule_browse_type is not None:
|
||||
|
||||
@@ -13,20 +13,52 @@ from services.state import safe_clear_user_logs, safe_remove_user_accounts
|
||||
# ==================== 用户管理/统计(管理员) ====================
|
||||
|
||||
|
||||
def _parse_optional_pagination(default_limit: int = 50, max_limit: int = 500) -> tuple[int | None, int]:
|
||||
limit_raw = request.args.get("limit")
|
||||
offset_raw = request.args.get("offset")
|
||||
if (limit_raw is None) and (offset_raw is None):
|
||||
return None, 0
|
||||
|
||||
try:
|
||||
limit = int(limit_raw if limit_raw is not None else default_limit)
|
||||
except (TypeError, ValueError):
|
||||
limit = default_limit
|
||||
limit = max(1, min(limit, max_limit))
|
||||
|
||||
try:
|
||||
offset = int(offset_raw if offset_raw is not None else 0)
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
offset = max(0, offset)
|
||||
return limit, offset
|
||||
|
||||
|
||||
@admin_api_bp.route("/users", methods=["GET"])
|
||||
@admin_required
|
||||
def get_all_users():
|
||||
"""获取所有用户"""
|
||||
users = database.get_all_users()
|
||||
return jsonify(users)
|
||||
limit, offset = _parse_optional_pagination()
|
||||
if limit is None:
|
||||
users = database.get_all_users()
|
||||
return jsonify(users)
|
||||
|
||||
users = database.get_all_users(limit=limit, offset=offset)
|
||||
total = database.get_users_count()
|
||||
return jsonify({"items": users, "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
@admin_api_bp.route("/users/pending", methods=["GET"])
|
||||
@admin_required
|
||||
def get_pending_users():
|
||||
"""获取待审核用户"""
|
||||
users = database.get_pending_users()
|
||||
return jsonify(users)
|
||||
limit, offset = _parse_optional_pagination(default_limit=30, max_limit=200)
|
||||
if limit is None:
|
||||
users = database.get_pending_users()
|
||||
return jsonify(users)
|
||||
|
||||
users = database.get_pending_users(limit=limit, offset=offset)
|
||||
total = database.get_users_count(status="pending")
|
||||
return jsonify({"items": users, "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
@admin_api_bp.route("/users/<int:user_id>/approve", methods=["POST"])
|
||||
|
||||
@@ -164,11 +164,13 @@ def update_account(account_id):
|
||||
"""
|
||||
UPDATE accounts
|
||||
SET password = ?, remember = ?
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(encrypted_password, new_remember, account_id),
|
||||
(encrypted_password, new_remember, account_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
if cursor.rowcount <= 0:
|
||||
return jsonify({"error": "账号不存在或无权限"}), 404
|
||||
|
||||
database.reset_account_login_status(account_id)
|
||||
logger.info(f"[账号更新] 用户 {user_id} 修改了账号 {account.username} 的密码,已重置登录状态")
|
||||
|
||||
@@ -9,6 +9,7 @@ import time as time_mod
|
||||
import uuid
|
||||
|
||||
import database
|
||||
from app_logger import get_logger
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_login import current_user, login_required
|
||||
from services.accounts_service import load_user_accounts
|
||||
@@ -17,6 +18,7 @@ from services.state import safe_get_account, safe_get_user_accounts_snapshot
|
||||
from services.tasks import submit_account_task
|
||||
|
||||
api_schedules_bp = Blueprint("api_schedules", __name__)
|
||||
logger = get_logger("app")
|
||||
|
||||
_HHMM_RE = re.compile(r"^(\d{1,2}):(\d{2})$")
|
||||
|
||||
@@ -391,4 +393,5 @@ def delete_schedule_logs_api(schedule_id):
|
||||
deleted = database.delete_schedule_logs(schedule_id, current_user.id)
|
||||
return jsonify({"success": True, "deleted": deleted})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
logger.warning(f"[schedules] 清空定时任务日志失败(schedule_id={schedule_id}): {e}")
|
||||
return jsonify({"error": "清空日志失败,请稍后重试"}), 500
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Iterator
|
||||
|
||||
import database
|
||||
from app_config import get_config
|
||||
from app_logger import get_logger
|
||||
from app_security import is_safe_path
|
||||
from flask import Blueprint, jsonify, request, send_from_directory
|
||||
from flask_login import current_user, login_required
|
||||
@@ -28,33 +29,89 @@ except AttributeError: # Pillow<9 fallback
|
||||
_RESAMPLE_FILTER = Image.LANCZOS
|
||||
|
||||
api_screenshots_bp = Blueprint("api_screenshots", __name__)
|
||||
logger = get_logger("app")
|
||||
|
||||
|
||||
def _get_user_prefix(user_id: int) -> str:
|
||||
return f"u{int(user_id)}"
|
||||
|
||||
|
||||
def _get_username(user_id: int) -> str:
|
||||
user_info = database.get_user_by_id(user_id)
|
||||
return user_info["username"] if user_info else f"user{user_id}"
|
||||
return str(user_info.get("username") or "") if user_info else ""
|
||||
|
||||
|
||||
def _is_user_screenshot(filename: str, username_prefix: str) -> bool:
|
||||
return filename.startswith(username_prefix + "_") and filename.lower().endswith(_IMAGE_EXTENSIONS)
|
||||
def _list_all_usernames() -> list[str]:
|
||||
users = database.get_all_users()
|
||||
result = []
|
||||
for row in users:
|
||||
username = str(row.get("username") or "").strip()
|
||||
if username:
|
||||
result.append(username)
|
||||
return result
|
||||
|
||||
|
||||
def _iter_user_screenshot_entries(username_prefix: str) -> Iterator[os.DirEntry]:
|
||||
def _resolve_user_owned_prefix(
|
||||
filename: str,
|
||||
*,
|
||||
user_id: int,
|
||||
username: str,
|
||||
all_usernames: list[str] | None = None,
|
||||
) -> str | None:
|
||||
lower_name = filename.lower()
|
||||
if not lower_name.endswith(_IMAGE_EXTENSIONS):
|
||||
return None
|
||||
|
||||
# 新版命名:u{user_id}_...
|
||||
id_prefix = _get_user_prefix(user_id)
|
||||
if filename.startswith(id_prefix + "_"):
|
||||
return id_prefix
|
||||
|
||||
# 兼容旧版命名:{username}_...
|
||||
username = str(username or "").strip()
|
||||
if not username:
|
||||
return None
|
||||
|
||||
if all_usernames is None:
|
||||
all_usernames = _list_all_usernames()
|
||||
|
||||
matched_usernames = [item for item in all_usernames if filename.startswith(item + "_")]
|
||||
if not matched_usernames:
|
||||
return None
|
||||
|
||||
# 取“最长匹配用户名”,避免 foo 越权读取 foo_bar 的截图。
|
||||
max_len = max(len(item) for item in matched_usernames)
|
||||
winners = [item for item in matched_usernames if len(item) == max_len]
|
||||
if len(winners) != 1:
|
||||
return None
|
||||
if winners[0] != username:
|
||||
return None
|
||||
return winners[0]
|
||||
|
||||
|
||||
def _iter_user_screenshot_entries(user_id: int, username: str, all_usernames: list[str]) -> Iterator[tuple[os.DirEntry, str]]:
|
||||
if not os.path.exists(SCREENSHOTS_DIR):
|
||||
return
|
||||
|
||||
with os.scandir(SCREENSHOTS_DIR) as entries:
|
||||
for entry in entries:
|
||||
if (not entry.is_file()) or (not _is_user_screenshot(entry.name, username_prefix)):
|
||||
if not entry.is_file():
|
||||
continue
|
||||
yield entry
|
||||
matched_prefix = _resolve_user_owned_prefix(
|
||||
entry.name,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
all_usernames=all_usernames,
|
||||
)
|
||||
if not matched_prefix:
|
||||
continue
|
||||
yield entry, matched_prefix
|
||||
|
||||
|
||||
def _build_display_name(filename: str) -> str:
|
||||
base_name, ext = filename.rsplit(".", 1)
|
||||
parts = base_name.split("_", 1)
|
||||
if len(parts) > 1:
|
||||
return f"{parts[1]}.{ext}"
|
||||
def _build_display_name(filename: str, owner_prefix: str) -> str:
|
||||
prefix = f"{owner_prefix}_"
|
||||
if filename.startswith(prefix):
|
||||
return filename[len(prefix) :]
|
||||
return filename
|
||||
|
||||
|
||||
@@ -126,11 +183,12 @@ def _parse_optional_pagination(default_limit: int = 24, *, max_limit: int = 100)
|
||||
def get_screenshots():
|
||||
"""获取当前用户的截图列表"""
|
||||
user_id = current_user.id
|
||||
username_prefix = _get_user_prefix(user_id)
|
||||
username = _get_username(user_id)
|
||||
|
||||
try:
|
||||
screenshots = []
|
||||
for entry in _iter_user_screenshot_entries(username_prefix):
|
||||
all_usernames = _list_all_usernames()
|
||||
for entry, matched_prefix in _iter_user_screenshot_entries(user_id, username, all_usernames):
|
||||
filename = entry.name
|
||||
stat = entry.stat()
|
||||
created_time = datetime.fromtimestamp(stat.st_mtime, tz=BEIJING_TZ)
|
||||
@@ -138,7 +196,7 @@ def get_screenshots():
|
||||
screenshots.append(
|
||||
{
|
||||
"filename": filename,
|
||||
"display_name": _build_display_name(filename),
|
||||
"display_name": _build_display_name(filename, matched_prefix),
|
||||
"size": stat.st_size,
|
||||
"created": created_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"_created_ts": stat.st_mtime,
|
||||
@@ -157,7 +215,8 @@ def get_screenshots():
|
||||
|
||||
return jsonify(screenshots)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
logger.warning(f"[screenshots] 获取截图列表失败(user_id={user_id}): {e}")
|
||||
return jsonify({"error": "获取截图列表失败"}), 500
|
||||
|
||||
|
||||
@api_screenshots_bp.route("/screenshots/<filename>")
|
||||
@@ -165,9 +224,8 @@ def get_screenshots():
|
||||
def serve_screenshot(filename):
|
||||
"""提供原图文件访问"""
|
||||
user_id = current_user.id
|
||||
username_prefix = _get_user_prefix(user_id)
|
||||
|
||||
if not _is_user_screenshot(filename, username_prefix):
|
||||
username = _get_username(user_id)
|
||||
if not _resolve_user_owned_prefix(filename, user_id=user_id, username=username):
|
||||
return jsonify({"error": "无权访问"}), 403
|
||||
|
||||
if not is_safe_path(SCREENSHOTS_DIR, filename):
|
||||
@@ -181,9 +239,8 @@ def serve_screenshot(filename):
|
||||
def serve_screenshot_thumbnail(filename):
|
||||
"""提供缩略图访问(失败时自动回退原图)"""
|
||||
user_id = current_user.id
|
||||
username_prefix = _get_user_prefix(user_id)
|
||||
|
||||
if not _is_user_screenshot(filename, username_prefix):
|
||||
username = _get_username(user_id)
|
||||
if not _resolve_user_owned_prefix(filename, user_id=user_id, username=username):
|
||||
return jsonify({"error": "无权访问"}), 403
|
||||
|
||||
if not is_safe_path(SCREENSHOTS_DIR, filename):
|
||||
@@ -209,9 +266,8 @@ def serve_screenshot_thumbnail(filename):
|
||||
def delete_screenshot(filename):
|
||||
"""删除指定截图"""
|
||||
user_id = current_user.id
|
||||
username_prefix = _get_user_prefix(user_id)
|
||||
|
||||
if not _is_user_screenshot(filename, username_prefix):
|
||||
username = _get_username(user_id)
|
||||
if not _resolve_user_owned_prefix(filename, user_id=user_id, username=username):
|
||||
return jsonify({"error": "无权删除"}), 403
|
||||
|
||||
if not is_safe_path(SCREENSHOTS_DIR, filename):
|
||||
@@ -226,7 +282,8 @@ def delete_screenshot(filename):
|
||||
return jsonify({"success": True})
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
logger.warning(f"[screenshots] 删除截图失败(user_id={user_id}, filename={filename}): {e}")
|
||||
return jsonify({"error": "删除截图失败"}), 500
|
||||
|
||||
|
||||
@api_screenshots_bp.route("/api/screenshots/clear", methods=["POST"])
|
||||
@@ -234,11 +291,12 @@ def delete_screenshot(filename):
|
||||
def clear_all_screenshots():
|
||||
"""清空当前用户的所有截图"""
|
||||
user_id = current_user.id
|
||||
username_prefix = _get_user_prefix(user_id)
|
||||
username = _get_username(user_id)
|
||||
|
||||
try:
|
||||
deleted_count = 0
|
||||
for entry in _iter_user_screenshot_entries(username_prefix):
|
||||
all_usernames = _list_all_usernames()
|
||||
for entry, _ in _iter_user_screenshot_entries(user_id, username, all_usernames):
|
||||
os.remove(entry.path)
|
||||
_remove_thumbnail(entry.name)
|
||||
deleted_count += 1
|
||||
@@ -246,4 +304,5 @@ def clear_all_screenshots():
|
||||
log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
|
||||
return jsonify({"success": True, "deleted": deleted_count})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
logger.warning(f"[screenshots] 清空截图失败(user_id={user_id}): {e}")
|
||||
return jsonify({"error": "清空截图失败"}), 500
|
||||
|
||||
Reference in New Issue
Block a user