diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index 1fbc9fd7..adf1152e 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -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 diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index eb1bedb3..858cf231 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -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") diff --git a/multiplayer-next.md b/multiplayer-next.md index 03fe26ac..16e4d4ec 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -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.