🐐 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
+34 -10
View File
@@ -1,13 +1,13 @@
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, selectinload
from game.card import compute_deck_type
from core.database import get_db
from core.dependencies import get_current_user
from core.dependencies import get_current_user, limiter, parse_uuid
from core.models import Card as CardModel
from core.models import Deck as DeckModel
from core.models import DeckCard as DeckCardModel
@@ -18,7 +18,10 @@ router = APIRouter()
class DeckUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=64)
card_ids: Optional[List[str]] = None
# The real constraint is the 50-cost rule enforced at game start, but cost is
# per-card so an unbounded list is still a cheap way to make us do arbitrary
# work. Cheapest cards are 1 cost, so 60 can't hide a legal deck.
card_ids: Optional[List[str]] = Field(None, max_length=60)
@router.get("/decks")
@@ -46,7 +49,8 @@ def get_decks(user: UserModel = Depends(get_current_user), db: Session = Depends
@router.post("/decks")
def create_deck(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
@limiter.limit("30/minute")
def create_deck(request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
count = db.query(DeckModel).filter(DeckModel.user_id == user.id).count()
deck = DeckModel(id=uuid.uuid4(), user_id=user.id, name=f"Deck #{count + 1}")
db.add(deck)
@@ -55,16 +59,36 @@ def create_deck(user: UserModel = Depends(get_current_user), db: Session = Depen
@router.patch("/decks/{deck_id}")
def update_deck(deck_id: str, body: DeckUpdate, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
# Generous: the deck editor saves on every change.
@limiter.limit("120/minute")
def update_deck(request: Request, deck_id: str, body: DeckUpdate, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(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")
if body.name is not None:
deck.name = body.name
if body.card_ids is not None:
try:
parsed_ids = [uuid.UUID(cid) for cid in body.card_ids]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid card IDs")
# deck_cards is keyed on (deck_id, card_id), so a repeated id would blow up
# on flush. dict.fromkeys dedupes while keeping the submitted order.
unique_ids = list(dict.fromkeys(parsed_ids))
# Without this the only constraint is the FK, so any card UUID that exists
# is accepted — including another user's. Public profiles hand those out.
owned = db.query(CardModel.id).filter(
CardModel.id.in_(unique_ids),
CardModel.user_id == user.id,
).all()
if len(owned) != len(unique_ids):
raise HTTPException(status_code=400, detail="Some cards are not in your collection")
db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete()
for card_id in body.card_ids:
db.add(DeckCardModel(deck_id=deck.id, card_id=uuid.UUID(card_id)))
for card_id in unique_ids:
db.add(DeckCardModel(deck_id=deck.id, card_id=card_id))
if deck.times_played > 0:
deck.wins = 0
deck.losses = 0
@@ -75,7 +99,7 @@ def update_deck(deck_id: str, body: DeckUpdate, user: UserModel = Depends(get_cu
@router.delete("/decks/{deck_id}")
def delete_deck(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
deck = db.query(DeckModel).filter(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")
if deck.times_played > 0:
@@ -88,7 +112,7 @@ def delete_deck(deck_id: str, user: UserModel = Depends(get_current_user), db: S
@router.get("/decks/{deck_id}/cards")
def get_deck_cards(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
deck = db.query(DeckModel).filter(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")
deck_cards = db.query(DeckCardModel).options(