fix(multiplayer): validate client revisions

This commit is contained in:
Josh Creek
2026-09-01 21:59:32 +01:00
parent f491725144
commit a60c1a097e
5 changed files with 35 additions and 4 deletions
+10 -2
View File
@@ -419,9 +419,9 @@ func _handle_websocket_packet(packet: PackedByteArray) -> void:
static func _valid_websocket_event(event: Dictionary) -> bool:
if not event.has("event") or not event["event"] is String or String(event["event"]).is_empty():
return false
if not event.has("revision") or not (event["revision"] is int or event["revision"] is float):
if not event.has("revision") or not _valid_revision(event["revision"]):
return false
if int(event["revision"]) < 0 or not event.has("resource_id") or not event["resource_id"] is String or String(event["resource_id"]).is_empty():
if not event.has("resource_id") or not event["resource_id"] is String or String(event["resource_id"]).is_empty():
return false
if not event.has("occurred_at") or not event["occurred_at"] is String or String(event["occurred_at"]).is_empty():
return false
@@ -437,6 +437,14 @@ static func _valid_websocket_event(event: Dictionary) -> bool:
return false
static func _valid_revision(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
func _on_resync_required(resource_id: String) -> void:
if not _operation.is_empty():
_pending_resync_resource_id = resource_id
+10 -2
View File
@@ -49,7 +49,7 @@ func begin_queue(new_ticket_id: String, new_playlist: String) -> bool:
func apply_ticket_update(update: Dictionary) -> bool:
if not _has_string(update, "ticket_id") or not update.has("revision") or not update.has("state"):
if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
return _request_resync(self.ticket_id)
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
return _request_resync(self.ticket_id)
@@ -94,7 +94,7 @@ 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 update.has("state"):
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)
var incoming_id := String(update["proposal_id"])
if proposal_id.is_empty():
@@ -270,3 +270,11 @@ func _is_legal_ticket_transition(from: String, to: String) -> bool:
func _has_string(value: Dictionary, key: String) -> bool:
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
func _valid_revision(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
@@ -60,6 +60,12 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void
assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted")
assignment.erase("server_id")
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
var fractional := envelope.duplicate()
fractional["revision"] = 1.5
assert_true(not ControlPlaneClient._valid_websocket_event(fractional), "fractional event revision is rejected")
var negative := envelope.duplicate()
negative["revision"] = -1
assert_true(not ControlPlaneClient._valid_websocket_event(negative), "negative event revision is rejected")
func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void:
@@ -75,6 +75,13 @@ func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() ->
assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase")
func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-revision", "casual")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-revision", "revision": 1.5, "state": "PROPOSED"}), "fractional ticket revision is rejected")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-revision", "revision": -1, "state": "OPEN"}), "negative proposal revision 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
@@ -1542,3 +1542,5 @@ The queue projection now also accepts the contract's post-allocation/result stat
Client ticket updates now enforce the versioned legal transition graph as well as revision ordering. Same-state heartbeat revisions remain valid, while higher-revision jumps and rewinds request authoritative recovery without mutating the visible phase; adversarial tests cover both boundaries.
Reconnect recovery now treats `COMPLETED` as terminal, avoiding a needless queue read after a finished match. UI policy tests cover the complete expanded lifecycle, including the completed-to-new-search boundary.
Ticket, proposal, and WebSocket revisions now fail closed unless they are finite, non-negative integers; fractional values are no longer silently truncated into valid revisions. Adversarial client tests cover fractional and negative inputs.