From 82691eaf805c617c1c1f94d3bedecf09343b29dc Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:29:45 +0100 Subject: [PATCH] fix(multiplayer): validate assignment expiry format --- Game/scripts/assignment_state.gd | 16 +++++++++++++--- Game/scripts/control_plane_client.gd | 2 +- Game/tests/cases/test_assignment_state.gd | 1 + multiplayer-next.md | 2 ++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Game/scripts/assignment_state.gd b/Game/scripts/assignment_state.gd index 5a0dcac9..20a9dea4 100644 --- a/Game/scripts/assignment_state.gd +++ b/Game/scripts/assignment_state.gd @@ -28,13 +28,16 @@ func apply(payload: Dictionary, expected_player_id: String = "") -> bool: var next_transport := String(payload["transport"]) var next_endpoint := String(payload["endpoint"]) var next_player_id := String(payload["player_id"]) - var expiry_unix := Time.get_unix_time_from_datetime_string(String(payload["expires_at"])) - if next_match_id.is_empty() or next_server_id.is_empty() or next_player_id.is_empty() or (not expected_player_id.is_empty() and next_player_id != expected_player_id) or int(payload["slot"]) < 0 or int(payload["slot"]) > 5 or int(payload["protocol_version"]) < 1 or (next_transport != "enet" and next_transport != "steam_sdr") or not _valid_endpoint(next_endpoint) or String(payload["expires_at"]).is_empty() or expiry_unix <= Time.get_unix_time_from_system() or String(payload["join_authorisation"]).is_empty(): + var next_expires_at := String(payload["expires_at"]) + if not is_valid_expiry_timestamp(next_expires_at): + return _reject("Assignment response contains invalid expiry") + var expiry_unix := Time.get_unix_time_from_datetime_string(next_expires_at) + if next_match_id.is_empty() or next_server_id.is_empty() or next_player_id.is_empty() or (not expected_player_id.is_empty() and next_player_id != expected_player_id) or int(payload["slot"]) < 0 or int(payload["slot"]) > 5 or int(payload["protocol_version"]) < 1 or (next_transport != "enet" and next_transport != "steam_sdr") or not _valid_endpoint(next_endpoint) or expiry_unix <= Time.get_unix_time_from_system() or String(payload["join_authorisation"]).is_empty(): return _reject("Assignment response contains invalid values") match_id = next_match_id server_id = next_server_id slot = int(payload["slot"]) - expires_at = String(payload["expires_at"]) + expires_at = next_expires_at protocol_version = int(payload["protocol_version"]) transport = next_transport endpoint = next_endpoint @@ -44,6 +47,13 @@ func apply(payload: Dictionary, expected_player_id: String = "") -> bool: return true +static func is_valid_expiry_timestamp(value: String) -> bool: + if value.is_empty(): + return false + var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$") + return timestamp_pattern.search(value) != null + + static func _valid_endpoint(value: String) -> bool: if value.is_empty() or value.contains("/") or value.contains("?") or value.contains("#"): return false diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index f6377f4f..8fecf1bf 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -233,7 +233,7 @@ func connect_to_assignment() -> Error: static func _assignment_is_fresh(value: AssignmentState) -> bool: - if value == null or value.expires_at.is_empty(): + if value == null or not AssignmentState.is_valid_expiry_timestamp(value.expires_at): return false var expiry := Time.get_unix_time_from_datetime_string(value.expires_at) return expiry > Time.get_unix_time_from_system() diff --git a/Game/tests/cases/test_assignment_state.gd b/Game/tests/cases/test_assignment_state.gd index 2c64cab8..2aa0c4d4 100644 --- a/Game/tests/cases/test_assignment_state.gd +++ b/Game/tests/cases/test_assignment_state.gd @@ -21,3 +21,4 @@ func test_assignment_projection_rejects_wrong_shape_or_unsafe_transport() -> voi assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-2", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "wrong player assignment is rejected") assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2000-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "expired assignment is rejected") assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "endpoint": "127.0.0.1", "join_authorisation": "signed"}, "player-1"), "unsafe endpoint is rejected") + assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "not-a-timestamp", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:30001", "join_authorisation": "signed"}, "player-1"), "malformed assignment expiry is rejected") diff --git a/multiplayer-next.md b/multiplayer-next.md index 449c2b88..ab32d037 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1573,4 +1573,6 @@ The UI now provides explicit detail copy for every non-terminal allocation and c Reconfiguring the client with new credentials now clears the prior session expiry, preventing an expired session’s timestamp from invalidating a fresh authentication. A re-authentication regression test covers the boundary. +Assignment expiry validation now fails closed on malformed non-empty timestamps before invoking the date parser, and fresh-assignment checks share the same format boundary. This prevents malformed assignment manifests from reaching transport startup. + 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.