🐐 Several security fixes

This commit is contained in:
2026-07-29 16:30:14 +02:00
parent b42abe5f5e
commit c56498239f
21 changed files with 660 additions and 204 deletions
+52 -23
View File
@@ -8,10 +8,9 @@ from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from services import notification_manager
from core.auth import decode_access_token
from core.database import get_db
from services.database_functions import fill_card_pool
from core.dependencies import get_current_user, get_user_id_from_request, limiter
from core.dependencies import accept_and_authenticate_ws, get_current_user, limiter, parse_uuid
from game.manager import (
QueueEntry, active_games, connections, create_challenge_game, create_solo_game,
handle_action, handle_disconnect, handle_timeout_claim, load_deck_cards,
@@ -28,6 +27,23 @@ from routers.notifications import _serialize_notification
router = APIRouter()
DECK_MAX_COST = 50
def deck_total_cost(deck_id: uuid.UUID, db: Session) -> int:
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck_id).all()]
return db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
def deck_cost_error(deck_id: uuid.UUID, db: Session, label: str = "Deck") -> str | None:
"""The 50-cost rule. Enforced at game start rather than at deck save, so every path
into a game has to check it — the challenge-accept path previously didn't."""
total = deck_total_cost(deck_id, db)
if total == 0 or total > DECK_MAX_COST:
return f"{label} total cost must be between 1 and {DECK_MAX_COST}"
return None
def _serialize_challenge(c: GameChallengeModel, current_user_id: uuid.UUID) -> dict:
deck = c.challenger_deck
return {
@@ -47,16 +63,22 @@ def _serialize_challenge(c: GameChallengeModel, current_user_id: uuid.UUID) -> d
@router.websocket("/ws/queue")
async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
user_id = await accept_and_authenticate_ws(websocket)
if not user_id:
return
# deck_id is a client-supplied query param. parse_uuid raises HTTPException, which
# means nothing on an open socket, so validate here and report it the way this
# handler reports everything else.
try:
parsed_deck_id = uuid.UUID(deck_id)
except ValueError:
await websocket.send_json({"type": "error", "message": "Invalid deck_id"})
await websocket.close(code=1008)
return
deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id),
DeckModel.id == parsed_deck_id,
DeckModel.user_id == uuid.UUID(user_id)
).first()
@@ -65,10 +87,9 @@ async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depen
await websocket.close(code=1008)
return
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
if total_cost == 0 or total_cost > 50:
await websocket.send_json({"type": "error", "message": "Deck total cost must be between 1 and 50"})
cost_err = deck_cost_error(deck.id, db)
if cost_err:
await websocket.send_json({"type": "error", "message": cost_err})
await websocket.close(code=1008)
return
@@ -91,12 +112,8 @@ async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depen
@router.websocket("/ws/game/{game_id}")
async def game_endpoint(websocket: WebSocket, game_id: str, db: Session = Depends(get_db)):
await websocket.accept()
token = await websocket.receive_text()
user_id = decode_access_token(token)
user_id = await accept_and_authenticate_ws(websocket)
if not user_id:
await websocket.close(code=1008)
return
if game_id not in active_games:
@@ -136,7 +153,7 @@ class AcceptGameChallengeRequest(BaseModel):
@router.post("/users/{username}/challenge")
@limiter.limit("10/minute", key_func=get_user_id_from_request)
@limiter.limit("10/minute")
async def create_game_challenge(
request: Request,
username: str,
@@ -197,7 +214,9 @@ async def create_game_challenge(
@router.post("/challenges/{challenge_id}/accept")
@limiter.limit("20/minute")
async def accept_game_challenge(
request: Request,
challenge_id: str,
req: AcceptGameChallengeRequest,
user: UserModel = Depends(get_current_user),
@@ -239,6 +258,12 @@ async def accept_game_challenge(
if not challenger_deck:
raise HTTPException(status_code=400, detail="The challenger's deck no longer exists")
# Both decks, because neither was cost-checked when the challenge was created.
for d, label in ((deck, "Your deck"), (challenger_deck, "The challenger's deck")):
cost_err = deck_cost_error(d.id, db, label)
if cost_err:
raise HTTPException(status_code=400, detail=cost_err)
try:
game_id = create_challenge_game(
str(challenge.challenger_id), str(challenge.challenger_deck_id),
@@ -282,7 +307,9 @@ async def accept_game_challenge(
@router.post("/challenges/{challenge_id}/decline")
@limiter.limit("20/minute")
async def decline_game_challenge(
request: Request,
challenge_id: str,
user: UserModel = Depends(get_current_user),
db: Session = Depends(get_db),
@@ -367,21 +394,23 @@ async def claim_timeout_win(game_id: str, user: UserModel = Depends(get_current_
@router.post("/game/solo")
async def start_solo_game(deck_id: str, difficulty: int = 5, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
# Tightest limit in the app: every call reserves 500 pool cards and leaves a game in
# memory that nothing reaps, so a loop here drains the shared pool and grows the heap.
@limiter.limit("10/hour")
async def start_solo_game(request: Request, deck_id: str, difficulty: int = 5, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
if difficulty < 1 or difficulty > 10:
raise HTTPException(status_code=400, detail="Difficulty must be between 1 and 10")
deck = db.query(DeckModel).filter(
DeckModel.id == uuid.UUID(deck_id),
DeckModel.id == parse_uuid(deck_id, "deck_id"),
DeckModel.user_id == user.id
).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
if total_cost == 0 or total_cost > 50:
raise HTTPException(status_code=400, detail="Deck total cost must be between 1 and 50")
cost_err = deck_cost_error(deck.id, db)
if cost_err:
raise HTTPException(status_code=400, detail=cost_err)
player_cards = load_deck_cards(deck_id, str(user.id), db)
if player_cards is None: