🐐 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
+46 -7
View File
@@ -163,6 +163,20 @@ async def send_error(ws: WebSocket, message: str):
await ws.send_json({"type": "error", "message": message})
def _frame_int(message: dict, field: str) -> int | None:
"""Non-negative int from a client WebSocket frame, or None if absent or malformed.
bool is rejected explicitly because it subclasses int, so True would pass the range
checks in rules.py and index slot 1. Everything else here is arbitrary client JSON:
a missing key used to raise KeyError and a string raised TypeError on comparison,
both of which killed the socket instead of returning an error frame.
"""
value = message.get(field)
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
return None
return value
## Matchmaking
def load_deck_cards(deck_id: str, user_id: str, db: Session) -> list | None:
@@ -175,7 +189,13 @@ def load_deck_cards(deck_id: str, user_id: str, db: Session) -> list | None:
deck_card_ids = [
dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()
]
cards = db.query(CardModel).filter(CardModel.id.in_(deck_card_ids)).all()
# The deck is owned by user_id, but its rows aren't necessarily: filtering on
# owner here means a deck that somehow got a foreign card in it can't bring
# that card into play (and can't bump times_played on someone else's row).
cards = db.query(CardModel).filter(
CardModel.id.in_(deck_card_ids),
CardModel.user_id == uuid.UUID(user_id),
).all()
return cards
async def try_match(db: Session):
@@ -183,6 +203,12 @@ async def try_match(db: Session):
if len(queue) < 2:
return
# Guard: same user queued twice (two tabs). Matching them would key `players` and
# `connections` on one id, collapsing both sides into one entry — and
# record_game_result would then credit the same account a win and a loss.
if queue[0].user_id == queue[1].user_id:
return
p1_entry = queue.pop(0)
p2_entry = queue.pop(0)
@@ -272,10 +298,14 @@ async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
err = None
if action == "play_card":
err = action_play_card(state, message["hand_index"], message["slot"])
hand_index = _frame_int(message, "hand_index")
slot = _frame_int(message, "slot")
if hand_index is None or slot is None:
err = "Invalid hand_index or slot"
else:
err = action_play_card(state, hand_index, slot)
if not err:
# Find the card that was just played
slot = message["slot"]
# action_play_card returned None, so slot is in range and the board holds it
card_instance = state.players[user_id].board[slot]
if card_instance:
try:
@@ -289,9 +319,12 @@ async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
logger.warning(f"Failed to increment times_played for card {card_instance.card_id}: {e}")
db.rollback()
elif action == "sacrifice":
slot = message.get("slot")
if slot is None:
err = "No slot provided"
slot = _frame_int(message, "slot")
# Bounds must be checked here, not left to action_sacrifice: the board is indexed
# below before that call, and slot=-1 would quietly read the last occupied slot
# and leak a sacrifice_animation for it to the opponent.
if slot is None or slot >= BOARD_SIZE:
err = "Invalid slot"
else:
# Find the card instance_id before it's removed
card = state.players[user_id].board[slot]
@@ -384,6 +417,12 @@ async def handle_timeout_claim(game_id: str, claimant_id: str, db: Session) -> s
state = active_games.get(game_id)
if not state:
return "Game not found"
# Authorization before anything else: the active-player check below passes for
# any non-participant, so without this an outsider with a game_id could name
# themselves winner. opponent_id() would then pick player_order[0] as the
# loser and the game would be popped out from under both real players.
if claimant_id not in state.players:
return "You are not in this game"
if state.result:
return "Game already ended"
if state.active_player_id == claimant_id: