fix(multiplayer): validate ticket timestamps

This commit is contained in:
Josh Creek
2026-09-01 22:08:19 +01:00
parent c08c761af3
commit 1e5825b096
3 changed files with 25 additions and 0 deletions
+14
View File
@@ -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
@@ -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")
+2
View File
@@ -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.