diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 90079a22..7898cfbc 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -300,6 +300,13 @@ static func is_valid_rfc3339_timestamp(value: String) -> bool: return timestamp_pattern.search(value) != null +static func is_valid_resource_id(value: String) -> bool: + if value.length() < 16 or value.length() > 128: + return false + var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$") + return resource_pattern.search(value) != null + + static func is_retryable_mutation_response(response_code: int) -> bool: return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500 @@ -474,7 +481,7 @@ static func _valid_websocket_event(event: Dictionary) -> bool: return false if not event.has("revision") or not _valid_revision(event["revision"]): return false - if not event.has("resource_id") or not event["resource_id"] is String or String(event["resource_id"]).is_empty(): + if not event.has("resource_id") or not event["resource_id"] is String or not is_valid_resource_id(String(event["resource_id"])): return false if not event.has("occurred_at") or not event["occurred_at"] is String or not is_valid_rfc3339_timestamp(String(event["occurred_at"])): return false diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 99d3ead4..534b8e46 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -54,7 +54,7 @@ func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void: func test_websocket_event_validation_requires_contract_specific_fields() -> void: - var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket-1", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"} + var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket_123456789", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"} assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted") var accepted := envelope.duplicate() accepted["state"] = "ACCEPTED" @@ -66,7 +66,7 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void var bad_state := envelope.duplicate() bad_state["state"] = "SECRET" assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected") - var assignment := {"event": "assignment_changed", "revision": 0, "resource_id": "match-1", "occurred_at": "2026-08-31T12:00:00Z", "match_id": "match-1", "server_id": "server-1"} + var assignment := {"event": "assignment_changed", "revision": 0, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "match_id": "match-1", "server_id": "server-1"} assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted") assignment.erase("server_id") assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected") @@ -79,6 +79,12 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void var malformed_time := envelope.duplicate() malformed_time["occurred_at"] = "yesterday" assert_true(not ControlPlaneClient._valid_websocket_event(malformed_time), "malformed event timestamp is rejected") + var short_resource := envelope.duplicate() + short_resource["resource_id"] = "short" + assert_true(not ControlPlaneClient._valid_websocket_event(short_resource), "short resource identifier is rejected") + var unsafe_resource := envelope.duplicate() + unsafe_resource["resource_id"] = "ticket_123456789/secret" + assert_true(not ControlPlaneClient._valid_websocket_event(unsafe_resource), "resource identifier with separators is rejected") func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void: diff --git a/multiplayer-next.md b/multiplayer-next.md index 275c316a..32b88f26 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1562,3 +1562,5 @@ Client sessions now fail closed at the expiry boundary and proactively clear cre Queue heartbeat and cancellation revision conflicts now schedule the same authoritative ticket recovery as proposal conflicts, preventing stale client actions from leaving the visible queue state unresolved. Adversarial operation/status/identity coverage is included. WebSocket event envelopes now require RFC3339 timestamps rather than merely non-empty text, matching the versioned contract; session-expiry format checks use the same boundary validator. Malformed-format adversarial coverage is included. + +WebSocket event resource identifiers now enforce the contract’s opaque 16–128 character allowlist, preventing path/separator text or undersized identifiers from entering the client projection.