From 3bd2387dc30c655b3d6e4a6a85803519244c27f9 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:50:00 +0100 Subject: [PATCH] fix(multiplayer): validate session expiry response --- Game/scripts/control_plane_client.gd | 9 ++++++++- Game/tests/cases/test_control_plane_client.gd | 11 +++++++++++ multiplayer-next.md | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 7a03a2d9..2d5c8118 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -294,6 +294,13 @@ static func is_session_expired(expires_at: String, now_unix: int = -1) -> bool: return expiry_unix <= current_unix +static func is_valid_session_response(payload: Dictionary) -> bool: + if not payload.has("expires_at") or not payload["expires_at"] is String: + return false + var expires_at := String(payload["expires_at"]) + return is_valid_rfc3339_timestamp(expires_at) and not is_session_expired(expires_at) + + static func is_valid_rfc3339_timestamp(value: String) -> bool: if value.is_empty(): return false @@ -429,7 +436,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head if operation == "steam_session": var returned_token := String(payload.get("access_token", "")) var returned_player_id := String(payload.get("player_id", "")) - if not is_valid_resource_id(returned_player_id) or not is_valid_access_token(returned_token): + if not is_valid_resource_id(returned_player_id) or not is_valid_access_token(returned_token) or not is_valid_session_response(payload): request_failed.emit(operation, response_code, "invalid session response") return player_id = returned_player_id diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 3a077e19..d9cd0d01 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -53,6 +53,17 @@ func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void: assert_true(ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00.123Z"), "fractional RFC3339 timestamp is accepted") assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31 12:00:00Z"), "space-separated timestamp is rejected") assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00"), "timezone-less timestamp is rejected") + var valid_session := {"player_id": "player_1234567890", "access_token": "session-id:opaque-token", "expires_at": "2099-08-31T12:00:00Z"} + assert_true(ControlPlaneClient.is_valid_session_response(valid_session), "future session response is accepted") + var missing_expiry := valid_session.duplicate() + missing_expiry.erase("expires_at") + assert_true(not ControlPlaneClient.is_valid_session_response(missing_expiry), "session without expiry is rejected") + var malformed_expiry := valid_session.duplicate() + malformed_expiry["expires_at"] = "tomorrow" + assert_true(not ControlPlaneClient.is_valid_session_response(malformed_expiry), "malformed session expiry is rejected") + var expired_session := valid_session.duplicate() + expired_session["expires_at"] = "2000-01-01T00:00:00Z" + assert_true(not ControlPlaneClient.is_valid_session_response(expired_session), "expired session response is rejected") func test_reconfiguration_discards_the_previous_session_expiry() -> void: diff --git a/multiplayer-next.md b/multiplayer-next.md index 5bb4a05b..0aae1daf 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1603,6 +1603,8 @@ Persisted matchmaking snapshots now apply the same opaque-ID validation to ticke Control-plane REST responses now fail closed on malformed ticket, proposal, or session player IDs before projection, covering the server-to-client JSON boundary as well as request paths. +Session establishment now also requires a present, syntactically valid, future `expires_at`, preventing malformed authentication responses from creating an unbounded client session. + 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.