From 9c5ef36fe86cb113c488de810e90628366a01afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A5=80=E6=A2=A6?= <3501646051@qq.com> Date: Sun, 17 May 2026 12:36:45 +0800 Subject: [PATCH] fix: path traversal via URL-encoded ../, Feb 29 leap year crash, missing response_model, dead code, duplicate utcnow --- api/app/main.py | 9 ++++++--- api/app/models/user_settings.py | 8 +------- api/app/routers/accounts.py | 25 ++++++------------------- api/app/routers/anniversaries.py | 18 ++++++++++++++---- api/app/schemas/account.py | 8 ++++++++ 5 files changed, 35 insertions(+), 33 deletions(-) diff --git a/api/app/main.py b/api/app/main.py index f953fcd..a26f283 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -151,9 +151,12 @@ if os.path.exists(WEBUI_PATH): @app.get("/{full_path:path}") async def spa_fallback(request: Request, full_path: str): """SPA 回退:先尝试提供真实文件,找不到则返回 index.html""" - file_path = os.path.join(WEBUI_PATH, full_path) - if os.path.isfile(file_path): - return FileResponse(file_path) + # 规范化路径并防止路径穿越攻击 + safe_path = os.path.normpath(os.path.join(WEBUI_PATH, full_path)) + if not safe_path.startswith(os.path.normpath(WEBUI_PATH)): + return FileResponse(os.path.join(WEBUI_PATH, "index.html")) + if os.path.isfile(safe_path): + return FileResponse(safe_path) return FileResponse(os.path.join(WEBUI_PATH, "index.html")) logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}") diff --git a/api/app/models/user_settings.py b/api/app/models/user_settings.py index 9705ef4..3525c20 100644 --- a/api/app/models/user_settings.py +++ b/api/app/models/user_settings.py @@ -1,12 +1,6 @@ from sqlalchemy import Column, Integer, String, Text, DateTime, Date -from datetime import datetime, timezone, date from app.database import Base - - -def utcnow(): - """统一获取 UTC 时间的工厂函数""" - return datetime.now(timezone.utc) - +from app.utils.datetime import utcnow class UserSettings(Base): """用户设置模型(单例,始终只有一条记录 id=1)""" diff --git a/api/app/routers/accounts.py b/api/app/routers/accounts.py index 5e9a10b..18f4943 100644 --- a/api/app/routers/accounts.py +++ b/api/app/routers/accounts.py @@ -8,7 +8,7 @@ from app.database import get_db from app.models.account import FinancialAccount, AccountHistory, DebtInstallment from app.schemas.account import ( AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest, - AccountHistoryResponse, AccountListItemResponse, + AccountHistoryResponse, AccountListItemResponse, PaginatedAccountHistoryResponse, DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse, ) from app.schemas.common import DeleteResponse @@ -232,7 +232,7 @@ def update_balance(account_id: int, data: BalanceUpdateRequest, db: Session = De raise HTTPException(status_code=500, detail="更新余额失败") -@router.get("/accounts/{account_id}/history") +@router.get("/accounts/{account_id}/history", response_model=PaginatedAccountHistoryResponse) def get_account_history( account_id: int, page: int = Query(1, ge=1), @@ -252,26 +252,13 @@ def get_account_history( AccountHistory.created_at.desc() ).offset((page - 1) * page_size).limit(page_size).all() - result = { + logger.info(f"获取账户历史成功: account_id={account_id}, total={total}") + return { "total": total, "page": page, "page_size": page_size, - "records": [ - { - "id": r.id, - "account_id": r.account_id, - "change_amount": r.change_amount, - "balance_before": r.balance_before, - "balance_after": r.balance_after, - "note": r.note, - "created_at": r.created_at, - } - for r in records - ] + "records": records, } - - logger.info(f"获取账户历史成功: account_id={account_id}, total={total}") - return result except HTTPException: raise except Exception as e: @@ -287,7 +274,7 @@ def get_installments(db: Session = Depends(get_db)): try: installments = db.query(DebtInstallment).order_by( DebtInstallment.is_completed.asc(), - DebtInstallment.next_payment_date.asc() if hasattr(DebtInstallment, 'next_payment_date') else DebtInstallment.id.asc() + DebtInstallment.id.asc() ).all() today = date.today() diff --git a/api/app/routers/anniversaries.py b/api/app/routers/anniversaries.py index 7a44acf..a402d14 100644 --- a/api/app/routers/anniversaries.py +++ b/api/app/routers/anniversaries.py @@ -17,6 +17,16 @@ from app.utils.logger import logger router = APIRouter(prefix="/api", tags=["纪念日"]) +def _safe_date(year: int, month: int, day: int) -> date: + """安全构造日期,对闰年 2 月 29 日回退到 2 月 28 日""" + try: + return date(year, month, day) + except ValueError: + if month == 2 and day == 29: + return date(year, 2, 28) + raise + + def compute_next_info(anniversary: Anniversary, today: date) -> tuple: """计算纪念日的下一次日期、距今天数、周年数""" month, day = anniversary.date.month, anniversary.date.day @@ -24,9 +34,9 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple: if anniversary.is_recurring: # 计算今年和明年的日期 this_year = today.year - next_date = date(this_year, month, day) + next_date = _safe_date(this_year, month, day) if next_date < today: - next_date = date(this_year + 1, month, day) + next_date = _safe_date(this_year + 1, month, day) days_until = (next_date - today).days @@ -38,14 +48,14 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple: else: # 非重复:使用原始日期(加上年份) if anniversary.year: - target = date(anniversary.year, month, day) + target = _safe_date(anniversary.year, month, day) if target < today: return None, None, None days_until = (target - today).days return target, days_until, 0 else: # 无年份的日期按今年算 - target = date(today.year, month, day) + target = _safe_date(today.year, month, day) if target < today: return None, None, None days_until = (target - today).days diff --git a/api/app/schemas/account.py b/api/app/schemas/account.py index d25503b..3fcd291 100644 --- a/api/app/schemas/account.py +++ b/api/app/schemas/account.py @@ -82,6 +82,14 @@ class AccountHistoryResponse(BaseModel): from_attributes = True +class PaginatedAccountHistoryResponse(BaseModel): + """分页账户变更历史响应模型""" + total: int + page: int + page_size: int + records: List[AccountHistoryResponse] = [] + + # ============ 分期还款计划 Schema ============ class DebtInstallmentBase(BaseModel):