From 1e5825b096a8cf3b9a036a50f42a8f38545fe7ee Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:08:19 +0100 Subject: [PATCH] fix(multiplayer): validate ticket timestamps --- Game/scripts/matchmaking_state.gd | 14 ++++++++++++++ Game/tests/cases/test_matchmaking_state.gd | 9 +++++++++ multiplayer-next.md | 2 ++ 3 files changed, 25 insertions(+) diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index 0c98f56c..6ac90c75 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -53,6 +53,10 @@ func apply_ticket_update(update: Dictionary) -> bool: return _request_resync(self.ticket_id) if update.has("playlist") and not _valid_playlist(String(update["playlist"])): return _request_resync(self.ticket_id) + if update.has("enqueued_at_unix") and not _valid_epoch(update["enqueued_at_unix"]): + return _request_resync(self.ticket_id) + if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]): + return _request_resync(self.ticket_id) if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id: return _request_resync(self.ticket_id) var incoming_revision := int(update["revision"]) @@ -98,6 +102,8 @@ func apply_ticket_update(update: Dictionary) -> bool: func apply_proposal_update(update: Dictionary) -> bool: if not _has_string(update, "proposal_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"): return _request_resync(proposal_id) + if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]): + return _request_resync(proposal_id) var incoming_id := String(update["proposal_id"]) if proposal_id.is_empty(): proposal_id = incoming_id @@ -312,3 +318,11 @@ func _valid_revision(value: Variant) -> bool: func _valid_playlist(value: String) -> bool: return value == "casual" or value == "ranked" + + +func _valid_epoch(value: Variant) -> bool: + if value is int: + return int(value) >= 0 + if value is float: + return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value)) + return false diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index e3ab1ba0..2f3acdca 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -90,6 +90,15 @@ func test_ticket_update_rejects_invalid_playlist_without_mutation() -> void: assert_eq(state.playlist, "casual", "invalid playlist cannot change playlist") +func test_ticket_and_proposal_epoch_metadata_rejects_malformed_values() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-epoch", "casual") + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "expires_at_unix": "not-a-time"}), "malformed ticket expiry is rejected") + assert_eq(state.phase, MatchmakingState.QUEUED, "malformed ticket expiry cannot change phase") + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-epoch", "revision": 1, "state": "PROPOSED", "enqueued_at_unix": -1}), "negative enqueue time is rejected") + assert_true(not state.apply_proposal_update({"proposal_id": "proposal-epoch", "revision": 1, "state": "OPEN", "expires_at_unix": 1.25}), "fractional proposal expiry is rejected") + + func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void: var state := MatchmakingState.new() state.begin_queue("ticket-1", "casual") diff --git a/multiplayer-next.md b/multiplayer-next.md index 4c59d4c7..4cd3007f 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1551,4 +1551,6 @@ Proposal decline/expiry/cancellation now leaves a still-proposed ticket in `QUEU Recovery targeting now follows the same boundary: only an `OPEN` proposal is polled as a proposal; terminal proposal outcomes fall back to the ticket recovery endpoint. This prevents repeated reads of a finished proposal from starving recovery of the requeued ticket. +Client queue/proposal expiry and enqueue epoch metadata now fail closed on malformed, negative, or fractional values instead of being silently coerced to zero. Adversarial metadata tests cover string, negative, and fractional timestamps. + Ticket projections now validate playlist metadata on every update, rejecting unknown values before either phase or playlist state can mutate. An adversarial higher-revision update test covers this boundary.