fix: path traversal via URL-encoded ../, Feb 29 leap year crash, missing response_model, dead code, duplicate utcnow

This commit is contained in:
祀梦
2026-05-17 12:36:45 +08:00
parent 5f23b8ef5b
commit 9c5ef36fe8
5 changed files with 35 additions and 33 deletions

View File

@@ -151,9 +151,12 @@ if os.path.exists(WEBUI_PATH):
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def spa_fallback(request: Request, full_path: str): async def spa_fallback(request: Request, full_path: str):
"""SPA 回退:先尝试提供真实文件,找不到则返回 index.html""" """SPA 回退:先尝试提供真实文件,找不到则返回 index.html"""
file_path = os.path.join(WEBUI_PATH, full_path) # 规范化路径并防止路径穿越攻击
if os.path.isfile(file_path): safe_path = os.path.normpath(os.path.join(WEBUI_PATH, full_path))
return FileResponse(file_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")) return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}") logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}")

View File

@@ -1,12 +1,6 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Date from sqlalchemy import Column, Integer, String, Text, DateTime, Date
from datetime import datetime, timezone, date
from app.database import Base from app.database import Base
from app.utils.datetime import utcnow
def utcnow():
"""统一获取 UTC 时间的工厂函数"""
return datetime.now(timezone.utc)
class UserSettings(Base): class UserSettings(Base):
"""用户设置模型(单例,始终只有一条记录 id=1""" """用户设置模型(单例,始终只有一条记录 id=1"""

View File

@@ -8,7 +8,7 @@ from app.database import get_db
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
from app.schemas.account import ( from app.schemas.account import (
AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest, AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest,
AccountHistoryResponse, AccountListItemResponse, AccountHistoryResponse, AccountListItemResponse, PaginatedAccountHistoryResponse,
DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse, DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse,
) )
from app.schemas.common import DeleteResponse 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="更新余额失败") 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( def get_account_history(
account_id: int, account_id: int,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -252,26 +252,13 @@ def get_account_history(
AccountHistory.created_at.desc() AccountHistory.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all() ).offset((page - 1) * page_size).limit(page_size).all()
result = { logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
return {
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
"records": [ "records": 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
]
} }
logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
return result
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -287,7 +274,7 @@ def get_installments(db: Session = Depends(get_db)):
try: try:
installments = db.query(DebtInstallment).order_by( installments = db.query(DebtInstallment).order_by(
DebtInstallment.is_completed.asc(), DebtInstallment.is_completed.asc(),
DebtInstallment.next_payment_date.asc() if hasattr(DebtInstallment, 'next_payment_date') else DebtInstallment.id.asc() DebtInstallment.id.asc()
).all() ).all()
today = date.today() today = date.today()

View File

@@ -17,6 +17,16 @@ from app.utils.logger import logger
router = APIRouter(prefix="/api", tags=["纪念日"]) 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: def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
"""计算纪念日的下一次日期、距今天数、周年数""" """计算纪念日的下一次日期、距今天数、周年数"""
month, day = anniversary.date.month, anniversary.date.day 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: if anniversary.is_recurring:
# 计算今年和明年的日期 # 计算今年和明年的日期
this_year = today.year this_year = today.year
next_date = date(this_year, month, day) next_date = _safe_date(this_year, month, day)
if next_date < today: 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 days_until = (next_date - today).days
@@ -38,14 +48,14 @@ def compute_next_info(anniversary: Anniversary, today: date) -> tuple:
else: else:
# 非重复:使用原始日期(加上年份) # 非重复:使用原始日期(加上年份)
if anniversary.year: if anniversary.year:
target = date(anniversary.year, month, day) target = _safe_date(anniversary.year, month, day)
if target < today: if target < today:
return None, None, None return None, None, None
days_until = (target - today).days days_until = (target - today).days
return target, days_until, 0 return target, days_until, 0
else: else:
# 无年份的日期按今年算 # 无年份的日期按今年算
target = date(today.year, month, day) target = _safe_date(today.year, month, day)
if target < today: if target < today:
return None, None, None return None, None, None
days_until = (target - today).days days_until = (target - today).days

View File

@@ -82,6 +82,14 @@ class AccountHistoryResponse(BaseModel):
from_attributes = True from_attributes = True
class PaginatedAccountHistoryResponse(BaseModel):
"""分页账户变更历史响应模型"""
total: int
page: int
page_size: int
records: List[AccountHistoryResponse] = []
# ============ 分期还款计划 Schema ============ # ============ 分期还款计划 Schema ============
class DebtInstallmentBase(BaseModel): class DebtInstallmentBase(BaseModel):