diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index fd9fa9b1..90079a22 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -282,8 +282,7 @@ static func is_valid_access_token(token: String) -> bool: static func is_session_expired(expires_at: String, now_unix: int = -1) -> bool: if expires_at.is_empty(): return false - var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$") - if timestamp_pattern.search(expires_at) == null: + if not is_valid_rfc3339_timestamp(expires_at): return true var expiry_unix := Time.get_unix_time_from_datetime_string(expires_at) if expiry_unix < 0: @@ -294,6 +293,13 @@ static func is_session_expired(expires_at: String, now_unix: int = -1) -> bool: return expiry_unix <= current_unix +static func is_valid_rfc3339_timestamp(value: String) -> bool: + if value.is_empty(): + return false + var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$") + return timestamp_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 @@ -470,7 +476,7 @@ static func _valid_websocket_event(event: Dictionary) -> bool: return false if not event.has("resource_id") or not event["resource_id"] is String or String(event["resource_id"]).is_empty(): return false - if not event.has("occurred_at") or not event["occurred_at"] is String or String(event["occurred_at"]).is_empty(): + 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 var event_name := String(event["event"]) if event_name == "assignment_changed": diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 6b0a374d..99d3ead4 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -48,6 +48,9 @@ func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void: assert_true(not ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 999), "session remains valid before expiry") assert_true(ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 1000), "session expires at the exact boundary") assert_true(ControlPlaneClient.is_session_expired("not-a-timestamp", 1000), "malformed non-empty expiry fails closed") + assert_true(ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00.123Z"), "fractional RFC3339 timestamp is accepted") + assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31 12:00:00Z"), "space-separated timestamp is rejected") + assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00"), "timezone-less timestamp is rejected") func test_websocket_event_validation_requires_contract_specific_fields() -> void: @@ -73,6 +76,9 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void var negative := envelope.duplicate() negative["revision"] = -1 assert_true(not ControlPlaneClient._valid_websocket_event(negative), "negative event revision is rejected") + var malformed_time := envelope.duplicate() + malformed_time["occurred_at"] = "yesterday" + assert_true(not ControlPlaneClient._valid_websocket_event(malformed_time), "malformed event timestamp 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 972ff880..275c316a 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1560,3 +1560,5 @@ Ticket projections now validate playlist metadata on every update, rejecting unk Client sessions now fail closed at the expiry boundary and proactively clear credentials before reconnects or authenticated requests. Boundary and malformed-expiry tests cover the lifecycle guard. 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.