fix(multiplayer): validate client resource paths

This commit is contained in:
Josh Creek
2026-09-01 22:45:04 +01:00
parent 56e8e2554c
commit e9ed8923c9
3 changed files with 15 additions and 7 deletions
+7 -7
View File
@@ -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)
@@ -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")
+2
View File
@@ -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.