From a10c6d47f96f4af905c38259d07a9bbf737c1817 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:34:59 +0100 Subject: [PATCH] fix(multiplayer): validate persisted matchmaking snapshots --- Game/scripts/matchmaking_state.gd | 27 ++++++++++++++++++++-- Game/tests/cases/test_matchmaking_state.gd | 3 +++ multiplayer-next.md | 2 ++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index 6ac90c75..1fbc9fd7 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -197,6 +197,25 @@ func restore_snapshot(saved: Dictionary) -> bool: _reset() if saved.is_empty(): return true + if saved.has("phase") and not saved["phase"] is String: + return false + if saved.has("ticket_id") and not saved["ticket_id"] is String: + return false + if saved.has("playlist") and not saved["playlist"] is String: + return false + if saved.has("proposal_id") and not saved["proposal_id"] is String: + return false + if saved.has("proposal_state") and not saved["proposal_state"] is String: + return false + if saved.has("message") and not saved["message"] is String: + return false + if saved.has("revision") and not _valid_revision(saved["revision"]): + return false + for epoch_key in ["enqueued_at_unix", "expires_at_unix"]: + if saved.has(epoch_key) and not _valid_epoch(saved[epoch_key]): + return false + if saved.has("proposal_revision") and not _valid_revision(saved["proposal_revision"]): + return false var saved_phase := String(saved.get("phase", IDLE)) var saved_ticket_id := String(saved.get("ticket_id", "")) if saved_ticket_id.is_empty() or not _is_ticket_state(saved_phase): @@ -204,15 +223,19 @@ func restore_snapshot(saved: Dictionary) -> bool: var saved_playlist := String(saved.get("playlist", "")) if saved_playlist != "casual" and saved_playlist != "ranked": return false + var saved_proposal_id := String(saved.get("proposal_id", "")) + var saved_proposal_state := String(saved.get("proposal_state", "")) + if saved_proposal_state not in ["", "OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"] or (not saved_proposal_state.is_empty() and saved_proposal_id.is_empty()): + return false ticket_id = saved_ticket_id playlist = saved_playlist phase = saved_phase revision = maxi(0, int(saved.get("revision", 0))) enqueued_at_unix = maxi(0, int(saved.get("enqueued_at_unix", 0))) expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0))) - proposal_id = String(saved.get("proposal_id", "")) + proposal_id = saved_proposal_id proposal_revision = maxi(0, int(saved.get("proposal_revision", 0))) - proposal_state = String(saved.get("proposal_state", "")) + proposal_state = saved_proposal_state message = "Recovering authoritative matchmaking state" needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED and phase != COMPLETED _emit_changed() diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index 2f3acdca..eb1bedb3 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -175,6 +175,9 @@ func test_restart_restore_requires_valid_identity_and_requests_authoritative_rec assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected") assert_eq(state.phase, MatchmakingState.IDLE, "invalid restore cannot leave stale active state") assert_true(not state.restore_snapshot({"phase": "NOT_A_STATE", "ticket_id": "ticket-1", "playlist": "casual"}), "unknown state is rejected") + assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "revision": "2"}), "string revision is rejected") + assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "enqueued_at_unix": 1.5}), "fractional epoch is rejected") + assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "proposal_state": "OPEN"}), "proposal state without identity is rejected") func test_authoritative_enqueue_time_survives_wait_projection_and_restore() -> void: diff --git a/multiplayer-next.md b/multiplayer-next.md index f7ace450..5db67535 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1581,6 +1581,8 @@ Ranked profile season metadata now validates optional expiry type and RFC3339 fo Ranked profile `season_id` now enforces the OpenAPI opaque-ID shape and exact string type, preventing undersized or coerced identifiers from entering the client projection. +Persisted matchmaking snapshots now validate field types, non-negative integral revisions/epochs, and proposal identity/state consistency before restoration; malformed restart data cannot be coerced into an active projection. + Signed MatchNet claims now also require exact JSON string/integer types for every identity, protocol, expiry, slot, team, and generation field; string-number coercion is rejected before canonical signature verification. Presentation progress: a shared `Game/themes/cosmic_clash_theme.tres` now gives the menu, lobby, matchmaking, and settings surfaces consistent button, input, option, and label styling. The custom-font portion of `TODO.md` remains open until a distributable font asset is selected.