From c580e46125ff70e4be6f795d6e7c02c064e86853 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:36:54 +0100 Subject: [PATCH] fix(multiplayer): fail closed on ticket timestamps --- Game/scripts/control_plane_client.gd | 13 +++++++++---- Game/tests/cases/test_control_plane_client.gd | 2 ++ multiplayer-next.md | 2 ++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 8fecf1bf..917624f3 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -318,10 +318,15 @@ static func should_recover_queue_after_conflict(operation: String, response_code static func normalize_ticket(payload: Dictionary) -> Dictionary: var result := payload.duplicate(true) - if result.has("enqueued_at") and result["enqueued_at"] is String: - result["enqueued_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["enqueued_at"])) - if result.has("expires_at") and result["expires_at"] is String: - result["expires_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["expires_at"])) + for pair in [["enqueued_at", "enqueued_at_unix"], ["expires_at", "expires_at_unix"]]: + var source_key: String = pair[0] + var target_key: String = pair[1] + if not result.has(source_key): + continue + if not result[source_key] is String or not is_valid_rfc3339_timestamp(String(result[source_key])): + result[target_key] = -1 + else: + result[target_key] = Time.get_unix_time_from_datetime_string(String(result[source_key])) return result diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 66242309..161d99d1 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -41,6 +41,8 @@ func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void: func test_ticket_normalization_derives_authoritative_enqueue_time() -> void: var normalized := ControlPlaneClient.normalize_ticket({"enqueued_at": "1970-01-01T00:16:40Z"}) assert_eq(int(normalized["enqueued_at_unix"]), 1000, "RFC3339 enqueue time is converted to epoch") + assert_eq(ControlPlaneClient.normalize_ticket({"enqueued_at": "not-a-timestamp"})["enqueued_at_unix"], -1, "malformed enqueue time remains visibly invalid") + assert_eq(ControlPlaneClient.normalize_ticket({"expires_at": 123})["expires_at_unix"], -1, "non-string expiry remains visibly invalid") func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void: diff --git a/multiplayer-next.md b/multiplayer-next.md index 7d2a09e8..5f01d7ce 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1583,6 +1583,8 @@ Ranked profile `season_id` now enforces the OpenAPI opaque-ID shape and exact st Persisted matchmaking snapshots now validate field types, non-negative integral revisions/epochs, and proposal identity/state consistency before restoration; malformed restart data cannot be coerced into an active projection. +Ticket timestamp normalization now preserves an invalid sentinel for malformed or non-string raw timestamps, allowing the projection to reject bad server metadata instead of silently converting it to epoch zero. + Ranked profile projection now rejects fractional `ranked_games` values instead of silently truncating them, matching the OpenAPI integer contract. 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.