From ec0bc362cd2031d39dda84575a8b1542e19c4f26 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:33:00 +0100 Subject: [PATCH] fix(multiplayer): validate ranked season expiry --- Game/scripts/ranked_profile_state.gd | 16 ++++++++++++++-- Game/tests/cases/test_control_plane_client.gd | 2 ++ multiplayer-next.md | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Game/scripts/ranked_profile_state.gd b/Game/scripts/ranked_profile_state.gd index a6575290..4419d1a8 100644 --- a/Game/scripts/ranked_profile_state.gd +++ b/Game/scripts/ranked_profile_state.gd @@ -39,13 +39,25 @@ func apply(payload: Dictionary) -> bool: provisional = bool(payload["provisional"]) season_id = String(payload.get("season_id", "")) season_ends_at_unix = 0 - if payload.has("season_ends_at") and payload["season_ends_at"] is String and not String(payload["season_ends_at"]).is_empty(): - season_ends_at_unix = maxi(0, int(Time.get_unix_time_from_datetime_string(String(payload["season_ends_at"])))) + if payload.has("season_ends_at"): + if not payload["season_ends_at"] is String or not is_valid_season_timestamp(String(payload["season_ends_at"])): + return _reject("Profile response contains invalid season expiry") + var parsed_season_end := Time.get_unix_time_from_datetime_string(String(payload["season_ends_at"])) + if parsed_season_end < 0: + return _reject("Profile response contains invalid season expiry") + season_ends_at_unix = int(parsed_season_end) available = true error_message = "" return true +static func is_valid_season_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 + + func set_error(reason: String) -> void: available = false error_message = reason diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 15465e29..8795316d 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -163,3 +163,5 @@ func test_ranked_profile_projects_and_bounds_season_countdown() -> void: assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "s1", "season_ends_at": "1970-01-03T00:00:00Z"}), "season end applies") assert_true(profile.display_text(1000).contains("Season ends in 2d"), "countdown rounds up remaining season time") assert_true(profile.display_text(300000).contains("Season ends in 0d"), "expired season countdown is clamped") + assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": "not-a-timestamp"}), "malformed season expiry is rejected") + assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": 123}), "non-string season expiry is rejected") diff --git a/multiplayer-next.md b/multiplayer-next.md index 6c420ad9..021e702e 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1577,6 +1577,8 @@ Assignment expiry validation now fails closed on malformed non-empty timestamps MatchNet join-authorisation admission now applies the same expiry format guard before parsing signed roster claims, closing the malformed-expiry gap at the transport handshake boundary. +Ranked profile season metadata now validates optional expiry type and RFC3339 format before deriving the UI countdown, rejecting malformed server projections instead of silently displaying a profile without season context. + 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.