fix(multiplayer): enforce opaque event resource ids

This commit is contained in:
Josh Creek
2026-09-01 22:22:34 +01:00
parent 87d43302e1
commit 17e6a9bc20
3 changed files with 18 additions and 3 deletions
+8 -1
View File
@@ -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
@@ -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:
+2
View File
@@ -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 contracts opaque 16128 character allowlist, preventing path/separator text or undersized identifiers from entering the client projection.