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")
+2
View File
@@ -1599,6 +1599,8 @@ Allocated `ServerConfig` startup now enforces the opaque match/server ID contrac
Authenticated client REST methods now enforce opaque ticket, proposal, and match IDs before constructing request paths, preventing malformed identifiers from crossing the URL boundary.
Persisted matchmaking snapshots now apply the same opaque-ID validation to ticket and proposal identities, preventing malformed restart state from entering recovery.
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.