diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 3fa960b3..7a03a2d9 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -429,7 +429,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 returned_player_id.is_empty() 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): request_failed.emit(operation, response_code, "invalid session response") return player_id = returned_player_id @@ -438,8 +438,18 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head session_expires_at = String(payload.get("expires_at", "")) connect_event_stream() session_changed.emit(player_id) - elif operation == "queue_create": - state.begin_queue(String(payload.get("ticket_id", "")), String(payload.get("playlist", ""))) + elif operation == "queue_create" or operation == "queue_recover" or operation == "queue_heartbeat" or operation == "queue_cancel": + if not _valid_response_opaque_id(payload, "ticket_id"): + state.fail("Queue response contains an invalid ticket identifier") + request_failed.emit(operation, response_code, "invalid ticket identifier") + return + if operation == "queue_create": + state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", ""))) + elif operation.begins_with("proposal_"): + if not _valid_response_opaque_id(payload, "proposal_id"): + state.fail("Proposal response contains an invalid proposal identifier") + request_failed.emit(operation, response_code, "invalid proposal identifier") + return if operation.begins_with("queue_"): state.apply_ticket_update(normalize_ticket(payload)) elif operation.begins_with("proposal_"): @@ -511,6 +521,10 @@ static func _valid_revision(value: Variant) -> bool: return false +static func _valid_response_opaque_id(payload: Dictionary, key: String) -> bool: + return payload.has(key) and payload[key] is String and is_valid_resource_id(String(payload[key])) + + func _on_resync_required(resource_id: String) -> void: if not _operation.is_empty(): _pending_resync_resource_id = resource_id diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 4eaa837f..3a077e19 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -151,6 +151,18 @@ func test_queue_conflict_response_handler_defers_ticket_recovery() -> void: client.free() +func test_rest_responses_reject_malformed_resource_identifiers() -> void: + var client := ControlPlaneClient.new() + client._ready() + client._operation = "queue_recover" + client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"ticket_id": "short", "playlist": "casual", "revision": 0, "state": "QUEUED"}).to_utf8_buffer()) + assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed queue response is not projected") + client._operation = "proposal_recover" + client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"proposal_id": "proposal/unsafe", "revision": 0, "state": "OPEN"}).to_utf8_buffer()) + assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed proposal response is not projected") + client.free() + + func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void: var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001") assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port") diff --git a/multiplayer-next.md b/multiplayer-next.md index 16e4d4ec..5bb4a05b 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1601,6 +1601,8 @@ Authenticated client REST methods now enforce opaque ticket, proposal, and match Persisted matchmaking snapshots now apply the same opaque-ID validation to ticket and proposal identities, preventing malformed restart state from entering recovery. +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. + 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.