From 80d6ee8cf52a356a685c86d353823e9a0ba90b31 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:18:31 +0100 Subject: [PATCH] fix(multiplayer): validate allocated roster shape --- Game/scripts/match_net.gd | 17 +++++++++++++---- Game/scripts/server_boot.gd | 2 +- Game/tests/cases/test_match_net.gd | 13 +++++++++++++ multiplayer-next.md | 2 ++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index 0423c9a9..e9a6cbfc 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -122,14 +122,23 @@ func configure_join_authorisations(tokens: Array, context: Dictionary, signing_k func assigned_player_slots() -> Array: var result: Array = [] + var seen_identities := {} + var seen_slots := {} for token in _allowed_join_authorisations.keys(): var claims := _join_claims(String(token)) if claims.is_empty(): - continue + return [] + var identity := str(claims.get("PlayerID", "")) + var team := int(claims.get("Team", -1)) + var slot := int(claims.get("Slot", -1)) + if identity.is_empty() or team < 0 or team >= TEAM_COUNT or slot < 0 or slot > 5 or slot / 3 != team or seen_identities.has(identity) or seen_slots.has(slot): + return [] + seen_identities[identity] = true + seen_slots[slot] = true result.append({ - "player_identity": str(claims.get("PlayerID", "")), - "team": int(claims.get("Team", -1)), - "slot": int(claims.get("Slot", -1)), + "player_identity": identity, + "team": team, + "slot": slot, }) result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a["slot"]) < int(b["slot"])) return result diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index cc034b9d..27a858e1 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -78,7 +78,7 @@ func _ready() -> void: "server_id": String(config.get_value("server-id")), "protocol": str(NetCodec.PROTOCOL_VERSION), "protocol_version": NetCodec.PROTOCOL_VERSION, - }, signing_key): + }, signing_key) or MatchNet.assigned_player_slots().size() != roster_tokens.size(): printerr("cosmic-clash-server: refusing to start with invalid join-authorisations-file") get_tree().quit(1) return diff --git a/Game/tests/cases/test_match_net.gd b/Game/tests/cases/test_match_net.gd index 4e3746a2..3017ff29 100644 --- a/Game/tests/cases/test_match_net.gd +++ b/Game/tests/cases/test_match_net.gd @@ -89,6 +89,19 @@ func test_allocated_join_authorisation_rejects_inconsistent_team_and_slot() -> v assert_true(not match_net._valid_join_authorisation(token), "a slot assigned to team 1 cannot claim team 0") +func test_assigned_roster_rejects_duplicate_identity_or_slot_shape() -> void: + var claims := { + "MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1", + "SteamID": "steam-1", "Slot": 0, "Team": 0, "Protocol": "1", + "Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z", + } + var first := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "one"}).to_utf8_buffer()) + var duplicate := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "two"}).to_utf8_buffer()) + var match_net := MatchNet.new() + assert_true(match_net.configure_join_authorisations([first, duplicate], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "duplicate fixture configures for structural inspection") + assert_eq(match_net.assigned_player_slots().size(), 0, "duplicate identity/slot roster fails closed") + + func test_allocated_join_authorisation_verifies_canonical_hmac() -> void: # This envelope is generated from server/domain.JoinAuthorisationBytes with # HMAC-SHA256(test-key), proving the Godot verifier agrees with the Go diff --git a/multiplayer-next.md b/multiplayer-next.md index 94e02d2d..3873d27f 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1404,3 +1404,5 @@ Allocated supervisor launch arguments now have a direct regression guard: author Read-only authenticated queue, proposal, assignment, legacy profile, and ranked-profile routes now emit lifecycle-safe observability events for successful, rejected, and not-found reads. An API regression exercises all five real HTTP routes and verifies the event set; event fields remain free of credentials. This closes the local read-route portion of task 8.44; metrics/traces export, dashboards, and alert routing remain operational work. Allocated join admission now retains and applies the signed assignment’s authoritative team and global slot: peer order can no longer rebalance a valid allocation, and inconsistent team/slot claims are rejected before roster admission. The server exposes the verified assignment list for allocation-aware startup and uses the per-team spawn index derived from the assigned slot. Go verification is clean; Godot execution remains blocked by the documented macOS pre-test crash. + +Allocated boot now also validates the complete signed roster shape before opening the gameplay endpoint: malformed claims, duplicate player identities, duplicate slots, and team/global-slot mismatches fail closed rather than leaving a partially usable server. The Godot `--check-only` attempt still reaches the known macOS renderer/ZSTD crash before script parsing, so this startup guard remains statically reviewed and covered by the existing signed-claim tests pending a working Godot runtime.