From e9ed8923c951b19d702263f5d5785ec1b2c1be53 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:45:04 +0100 Subject: [PATCH] fix(multiplayer): validate client resource paths --- Game/scripts/control_plane_client.gd | 14 +++++++------- Game/tests/cases/test_control_plane_client.gd | 6 ++++++ multiplayer-next.md | 2 ++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index c4ba8cc2..3fa960b3 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -129,7 +129,7 @@ static func websocket_url(url: String) -> String: func queue_create(ticket_id: String, playlist: String, client_build: String, protocol_version: int) -> Error: - if ticket_id.is_empty() or (playlist != "casual" and playlist != "ranked") or client_build.is_empty() or protocol_version < 1: + if not is_valid_resource_id(ticket_id) or (playlist != "casual" and playlist != "ranked") or client_build.is_empty() or protocol_version < 1: return ERR_INVALID_PARAMETER if not state.begin_queue(ticket_id, playlist): return ERR_INVALID_PARAMETER @@ -176,13 +176,13 @@ func can_retry_last_mutation() -> bool: func recover_queue(ticket_id: String) -> Error: - if ticket_id.is_empty(): + if not is_valid_resource_id(ticket_id): return ERR_INVALID_PARAMETER return _start_request("queue_recover", HTTPClient.METHOD_GET, "/v1/queue/" + ticket_id, {}, "") func recover_proposal(proposal_id: String) -> Error: - if proposal_id.is_empty(): + if not is_valid_resource_id(proposal_id): return ERR_INVALID_PARAMETER return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "") @@ -200,7 +200,7 @@ func fetch_ranked_profile() -> Error: func fetch_assignment(match_id: String) -> Error: - if match_id.is_empty() or player_id.is_empty(): + if not is_valid_resource_id(match_id) or player_id.is_empty(): return ERR_INVALID_PARAMETER return _start_request("assignment", HTTPClient.METHOD_GET, "/v1/assignments/" + match_id, {}, "") @@ -247,19 +247,19 @@ static func _split_assignment_endpoint(value: String) -> Dictionary: func heartbeat(ticket_id: String, expected_revision: int) -> Error: - if ticket_id.is_empty() or expected_revision < 0: + if not is_valid_resource_id(ticket_id) or expected_revision < 0: return ERR_INVALID_PARAMETER return _start_request("queue_heartbeat", HTTPClient.METHOD_POST, "/v1/queue/%s/heartbeat" % ticket_id, {}, _idempotency_key("heartbeat"), expected_revision) func cancel_queue(ticket_id: String, expected_revision: int) -> Error: - if ticket_id.is_empty() or expected_revision < 0 or not state.can_cancel(): + if not is_valid_resource_id(ticket_id) or expected_revision < 0 or not state.can_cancel(): return ERR_INVALID_PARAMETER return _start_request("queue_cancel", HTTPClient.METHOD_POST, "/v1/queue/%s/cancel" % ticket_id, {}, _idempotency_key("cancel"), expected_revision) func respond_to_proposal(proposal_id: String, accept: bool, expected_revision: int) -> Error: - if proposal_id.is_empty() or expected_revision < 0: + if not is_valid_resource_id(proposal_id) or expected_revision < 0: return ERR_INVALID_PARAMETER var action := "accept" if accept else "decline" return _start_request("proposal_" + action, HTTPClient.METHOD_POST, "/v1/proposals/%s/%s" % [proposal_id, action], {}, _idempotency_key("proposal"), expected_revision) diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 0fb167ed..4eaa837f 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -123,6 +123,12 @@ func test_retryable_mutation_policy_only_retries_safe_failures() -> void: assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed") +func test_rest_resource_identifiers_use_the_opaque_contract_shape() -> void: + assert_true(ControlPlaneClient.is_valid_resource_id("ticket_1234567890"), "contract-sized resource id is accepted") + assert_true(not ControlPlaneClient.is_valid_resource_id("ticket-1"), "short resource id is rejected") + assert_true(not ControlPlaneClient.is_valid_resource_id("ticket_1234567890/path"), "path separator is rejected") + + func test_queue_revision_conflicts_schedule_authoritative_recovery() -> void: assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 409, "ticket-1"), "stale heartbeat recovers the queue ticket") assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, "ticket-1"), "stale cancellation recovers the queue ticket") diff --git a/multiplayer-next.md b/multiplayer-next.md index bd22a464..03fe26ac 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1597,6 +1597,8 @@ Assignment projection now rejects fractional `slot` and `protocol_version` value Allocated `ServerConfig` startup now enforces the opaque match/server ID contract, rejecting short or unsafe allocation flags before process launch. +Authenticated client REST methods now enforce opaque ticket, proposal, and match IDs before constructing request paths, preventing malformed identifiers from crossing the URL 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.