From 3985b74bcfdff3e7669a77fadacfe898746e98ab Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:33:54 +0100 Subject: [PATCH] fix(multiplayer): validate ranked season ids --- Game/scripts/ranked_profile_state.gd | 13 ++++++++++++- Game/tests/cases/test_control_plane_client.gd | 6 ++++-- multiplayer-next.md | 2 ++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Game/scripts/ranked_profile_state.gd b/Game/scripts/ranked_profile_state.gd index 4419d1a8..63d4c2d8 100644 --- a/Game/scripts/ranked_profile_state.gd +++ b/Game/scripts/ranked_profile_state.gd @@ -37,7 +37,11 @@ func apply(payload: Dictionary) -> bool: ranked_games = next_games tier = next_tier provisional = bool(payload["provisional"]) - season_id = String(payload.get("season_id", "")) + season_id = "" + if payload.has("season_id"): + if not payload["season_id"] is String or not is_valid_opaque_id(String(payload["season_id"])): + return _reject("Profile response contains invalid season identifier") + season_id = String(payload["season_id"]) season_ends_at_unix = 0 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"])): @@ -58,6 +62,13 @@ static func is_valid_season_timestamp(value: String) -> bool: return timestamp_pattern.search(value) != null +static func is_valid_opaque_id(value: String) -> bool: + if value.length() < 16 or value.length() > 128: + return false + var id_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$") + return id_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 8795316d..404afc0c 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -150,7 +150,7 @@ func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void: func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> void: var profile := RankedProfileState.new() - assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "s1"}), "valid profile applies") + assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "season_1234567890"}), "valid profile applies") assert_eq(profile.display_text(), "Provisional ยท 3 ranked games", "provisional status overrides tier presentation") assert_true(not profile.apply({"rating": -1.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": false}), "negative rating is rejected") assert_true(not profile.available, "unsafe response is not displayed") @@ -160,8 +160,10 @@ func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> func test_ranked_profile_projects_and_bounds_season_countdown() -> void: var profile := RankedProfileState.new() - 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.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "season_1234567890", "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") + assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "short"}), "short season identifier is rejected") + assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": 123}), "non-string season identifier is rejected") diff --git a/multiplayer-next.md b/multiplayer-next.md index 021e702e..f7ace450 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1579,6 +1579,8 @@ MatchNet join-authorisation admission now applies the same expiry format guard b 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. +Ranked profile `season_id` now enforces the OpenAPI opaque-ID shape and exact string type, preventing undersized or coerced identifiers from entering the client projection. + 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.