fix(multiplayer): validate persisted matchmaking ids

This commit is contained in:
Josh Creek
2026-09-01 22:46:40 +01:00
parent e9ed8923c9
commit bda3fc5aff
3 changed files with 16 additions and 5 deletions
+9 -2
View File
@@ -218,14 +218,14 @@ func restore_snapshot(saved: Dictionary) -> bool:
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):
if not _valid_opaque_id(saved_ticket_id) or not _is_ticket_state(saved_phase):
return false
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()):
if saved_proposal_state not in ["", "OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"] or (not saved_proposal_state.is_empty() and not _valid_opaque_id(saved_proposal_id)) or (saved_proposal_state.is_empty() and not saved_proposal_id.is_empty() and not _valid_opaque_id(saved_proposal_id)):
return false
ticket_id = saved_ticket_id
playlist = saved_playlist
@@ -349,3 +349,10 @@ func _valid_epoch(value: Variant) -> bool:
if value is float:
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
return false
func _valid_opaque_id(value: String) -> bool:
if value.length() < 16 or value.length() > 128:
return false
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
return resource_pattern.search(value) != null
+5 -3
View File
@@ -169,7 +169,7 @@ func test_expiry_is_distinct_from_generic_failure_and_remains_visible() -> void:
func test_restart_restore_requires_valid_identity_and_requests_authoritative_recovery() -> void:
var state := MatchmakingState.new()
assert_true(state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-1", "playlist": "casual", "revision": 2}), "valid active snapshot restores")
assert_true(state.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket_1234567890", "playlist": "casual", "revision": 2}), "valid active snapshot restores")
assert_true(state.needs_resync, "restored active state must recover from the server")
assert_eq(state.revision, 2, "revision is retained for diagnostics")
assert_true(not state.restore_snapshot({"phase": "QUEUED", "ticket_id": "", "playlist": "casual"}), "missing ticket identity is rejected")
@@ -182,9 +182,11 @@ func test_restart_restore_requires_valid_identity_and_requests_authoritative_rec
func test_authoritative_enqueue_time_survives_wait_projection_and_restore() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-wait", "casual"), "queue setup succeeds")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-wait", "revision": 0, "state": "QUEUED", "playlist": "casual", "enqueued_at_unix": 1000}), "server enqueue timestamp applies")
assert_true(state.begin_queue("ticket_wait_123456", "casual"), "queue setup succeeds")
assert_true(state.apply_ticket_update({"ticket_id": "ticket_wait_123456", "revision": 0, "state": "QUEUED", "playlist": "casual", "enqueued_at_unix": 1000}), "server enqueue timestamp applies")
assert_eq(state.waited_seconds(1065), 65, "wait uses server enqueue time")
var restored := MatchmakingState.new()
assert_true(restored.restore_snapshot(state.snapshot()), "snapshot restores")
assert_eq(restored.waited_seconds(1065), 65, "authoritative wait survives restore")
assert_true(not restored.restore_snapshot({"phase": "QUEUED", "ticket_id": "ticket-short", "playlist": "casual"}), "short snapshot ticket id is rejected")
assert_true(not restored.restore_snapshot({"phase": "PROPOSED", "ticket_id": "ticket_wait_123456", "playlist": "casual", "proposal_id": "proposal/unsafe", "proposal_state": "OPEN"}), "unsafe snapshot proposal id is rejected")