🐐 Several security fixes
This commit is contained in:
+46
-7
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user