安全修复:加固CSRF与凭证保护并修复越权风险

This commit is contained in:
2026-02-16 01:19:43 +08:00
parent 14b506e8a1
commit 1389ec7434
22 changed files with 375 additions and 83 deletions

View File

@@ -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