From d819658ace5f237d8fdf268ea673c8b2935a42aa Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:32:05 +0100 Subject: [PATCH] fix(multiplayer): reject typed claim coercion --- Game/scripts/match_net.gd | 16 +++++++++++++++- Game/tests/cases/test_match_net.gd | 4 ++++ multiplayer-next.md | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index a2adbde6..97d40643 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -335,7 +335,13 @@ func _valid_join_authorisation(token: String) -> bool: var claims = envelope["Authorisation"] if not claims is Dictionary: return false - if str(claims.get("PlayerID", "")).is_empty(): + for string_claim in ["MatchID", "ServerID", "PlayerID", "SteamID", "Protocol", "ExpiresAt"]: + if not claims.has(string_claim) or not claims[string_claim] is String or String(claims[string_claim]).is_empty(): + return false + for integer_claim in ["Slot", "Team", "Generation"]: + if not claims.has(integer_claim) or not _valid_integer_claim(claims[integer_claim]): + return false + if not envelope["Signature"] is String or String(envelope["Signature"]).is_empty(): return false var claimed_team := int(claims.get("Team", -1)) var claimed_slot := int(claims.get("Slot", -1)) @@ -374,6 +380,14 @@ func _valid_join_authorisation(token: String) -> bool: and expiry > Time.get_unix_time_from_system() +static func _valid_integer_claim(value: Variant) -> bool: + if value is int: + return int(value) >= 0 + if value is float: + return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value)) + return false + + func _join_identity(token: String) -> String: if token.is_empty(): return "" diff --git a/Game/tests/cases/test_match_net.gd b/Game/tests/cases/test_match_net.gd index 6f042bc3..901ecf1f 100644 --- a/Game/tests/cases/test_match_net.gd +++ b/Game/tests/cases/test_match_net.gd @@ -78,6 +78,10 @@ func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> v malformed_claims["ExpiresAt"] = "tomorrow" var malformed_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": malformed_claims, "Signature": "trusted-signature"}).to_utf8_buffer()) assert_true(not match_net._valid_join_authorisation(malformed_token), "malformed expiry claim is rejected before admission") + var string_slot_claims := claims.duplicate() + string_slot_claims["Slot"] = "5" + var string_slot_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": string_slot_claims, "Signature": "trusted-signature"}).to_utf8_buffer()) + assert_true(not match_net._valid_join_authorisation(string_slot_token), "string slot claim is rejected instead of coerced") assert_true(not match_net._valid_join_authorisation(token + "tampered"), "token mutation is rejected") var wrong_claims := claims.duplicate() wrong_claims["ServerID"] = "other-server" diff --git a/multiplayer-next.md b/multiplayer-next.md index 3ca27982..6c420ad9 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1577,4 +1577,6 @@ 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. +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.