Commit 26cf91b016 for woocommerce
commit 26cf91b01618e558c4e418b29ce694c388e0fc48
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date: Wed Nov 26 11:49:42 2025 +0900
Fix Tracks events rejected due to bracket notation in array properties (#61729)
* Fix Tracks events rejected due to bracket notation in array properties
When arrays are passed to WC_Tracks::record_event(), http_build_query()
creates bracket notation in property names (e.g., prop[0], prop[1]) which
violates the required naming pattern and causes event rejection.
This fix implements automatic array sanitization in validate_and_sanitize():
- Empty arrays → empty string
- Indexed arrays with scalars → comma-separated string (URL-encoded)
- Associative/nested arrays → JSON string
Aligns with Jetpack consumer-side fix: https://github.com/Automattic/jetpack/pull/45544
Changes:
- Add sanitize_property_values() method (PHP 7.4+ compatible)
- Integrate into validate_and_sanitize() preserving object return type
- Document array handling in WC_Tracks::record_event() PHPDoc
- Add 8 comprehensive unit tests for all array scenarios
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Add changefile(s) from automation for the following project(s): woocommerce
* Refactor WC_Tracks_Event to remove rawurlencode for indexed arrays
Updated the handling of indexed arrays in WC_Tracks_Event to directly use implode for creating comma-separated strings, removing unnecessary URL encoding. Adjusted unit tests to verify the expected string outputs without checking for bracket notation or JSON encoding, ensuring cleaner assertions for URL parameters.
Changes:
- Simplified value assignment for indexed arrays
- Updated assertions in unit tests to check for string containment instead of exact matches
This change aligns with the recent improvements in array handling for event tracking.
* Update plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Fix lint
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/61729-fix-tracks-array-serialization b/plugins/woocommerce/changelog/61729-fix-tracks-array-serialization
new file mode 100644
index 0000000000..ddd29f7c6c
--- /dev/null
+++ b/plugins/woocommerce/changelog/61729-fix-tracks-array-serialization
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Tracks events being rejected when array properties are passed by automatically converting arrays to valid formats
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php b/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
index 4d2bbe1688..3e3d9a656f 100644
--- a/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
+++ b/plugins/woocommerce/includes/tracks/class-wc-tracks-event.php
@@ -66,7 +66,7 @@ class WC_Tracks_Event {
* Annotate the event with all relevant info.
*
* @param array $event Event arguments.
- * @return bool|WP_Error True on success, WP_Error on failure.
+ * @return object|WP_Error Event object on success, WP_Error on failure.
*/
public static function validate_and_sanitize( $event ) {
$event = (object) $event;
@@ -103,6 +103,9 @@ class WC_Tracks_Event {
}
}
+ // Sanitize array values to prevent bracket notation in serialization.
+ $_event = self::sanitize_property_values( $_event );
+
return $_event;
}
@@ -131,6 +134,54 @@ class WC_Tracks_Event {
return esc_url_raw( WC_Tracks_Client::PIXEL . '?' . http_build_query( $validated ) );
}
+ /**
+ * Sanitize property values to ensure they can be safely serialized.
+ *
+ * Converts array values to appropriate formats to prevent http_build_query()
+ * from creating bracket notation (e.g., prop[0], prop[1]) which violates
+ * the property name regex.
+ *
+ * @param object|array $properties Event properties as object or array.
+ * @return object|array Sanitized properties in the same type as input.
+ */
+ private static function sanitize_property_values( $properties ) {
+ $is_object = is_object( $properties );
+ $props = $is_object ? get_object_vars( $properties ) : $properties;
+
+ foreach ( $props as $key => $value ) {
+ if ( ! is_array( $value ) ) {
+ continue;
+ }
+
+ if ( ! $value ) {
+ // Empty array becomes empty string.
+ $props[ $key ] = '';
+ continue;
+ }
+
+ // Check if array is indexed (not associative) and contains only scalar values.
+ $is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
+ $has_scalar_only = ! array_filter(
+ $value,
+ function ( $item ) {
+ return is_array( $item ) || is_object( $item );
+ }
+ );
+
+ if ( $is_indexed_array && $has_scalar_only ) {
+ // Indexed arrays with scalar values: join as comma string.
+ $props[ $key ] = implode( ',', array_map( 'strval', $value ) );
+ continue;
+ }
+
+ // Associative arrays or nested arrays become JSON strings.
+ $encoded = wp_json_encode( $value, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES );
+ $props[ $key ] = ( false === $encoded ) ? '' : $encoded;
+ }
+
+ return $is_object ? (object) $props : $props;
+ }
+
/**
* Check if event name is valid.
*
diff --git a/plugins/woocommerce/includes/tracks/class-wc-tracks.php b/plugins/woocommerce/includes/tracks/class-wc-tracks.php
index 6e5ded362f..86d447b9e0 100644
--- a/plugins/woocommerce/includes/tracks/class-wc-tracks.php
+++ b/plugins/woocommerce/includes/tracks/class-wc-tracks.php
@@ -92,6 +92,26 @@ class WC_Tracks {
* Record an event in Tracks - this is the preferred way to record events from PHP.
* Note: the event request won't be made if $properties has a member called `error`.
*
+ * Array values in event properties are automatically converted to prevent invalid property names:
+ * - Indexed arrays (e.g., ['a', 'b', 'c']) become comma-separated strings: 'a,b,c'
+ * - Associative arrays (e.g., ['key' => 'val']) become JSON strings: '{"key":"val"}'
+ * - Empty arrays become empty strings
+ *
+ * Examples:
+ * // Indexed array - becomes comma-separated string
+ * WC_Tracks::record_event( 'checkout_viewed', array(
+ * 'blocks' => array( 'woocommerce/cart-items', 'woocommerce/checkout-totals' )
+ * ) );
+ * // Results in: blocks=woocommerce%2Fcart-items%2Cwoocommerce%2Fcheckout-totals
+ *
+ * // Associative array - becomes JSON string
+ * WC_Tracks::record_event( 'settings_changed', array(
+ * 'options' => array( 'enabled' => true, 'count' => 5 )
+ * ) );
+ * // Results in: options=%7B%22enabled%22%3Atrue%2C%22count%22%3A5%7D
+ *
+ * For complex structures, consider explicitly JSON-encoding before passing to record_event().
+ *
* @param string $event_name The name of the event.
* @param array $event_properties Custom properties to send with the event.
* @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-tracks-test.php b/plugins/woocommerce/tests/php/includes/class-wc-tracks-test.php
index c854522c8d..1f49fda0f6 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-tracks-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-tracks-test.php
@@ -267,4 +267,219 @@ class WC_Tracks_Test extends \WC_Unit_Test_Case {
// Clean up.
delete_option( \WC_Install::STORE_ID_OPTION );
}
+
+ /**
+ * Test that indexed arrays are converted to comma-separated strings.
+ */
+ public function test_array_property_indexed_array() {
+ $event_props = array(
+ '_en' => 'test_event',
+ '_ts' => WC_Tracks_Client::build_timestamp(),
+ 'blocks' => array( 'block-a', 'block-b', 'block-c' ),
+ 'simple_prop' => 'value',
+ );
+
+ $event = new \WC_Tracks_Event( $event_props );
+ $url = $event->build_pixel_url();
+
+ // Verify URL was built successfully.
+ $this->assertNotEmpty( $url );
+ $this->assertStringContainsString( 'block-a%2Cblock-b%2Cblock-c', $url );
+
+ // Parse the query string.
+ $parsed_url = wp_parse_url( $url );
+ parse_str( $parsed_url['query'], $query_params );
+ // Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+ foreach ( $query_params as $key => $value ) {
+ $this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+ }
+ }
+
+ /**
+ * Test that associative arrays are converted to JSON strings.
+ */
+ public function test_array_property_associative_array() {
+ $event_props = array(
+ '_en' => 'test_event',
+ '_ts' => WC_Tracks_Client::build_timestamp(),
+ 'options' => array(
+ 'enabled' => true,
+ 'count' => 5,
+ 'name' => 'test',
+ ),
+ );
+
+ $event = new \WC_Tracks_Event( $event_props );
+ $url = $event->build_pixel_url();
+
+ // Verify URL was built successfully.
+ $this->assertNotEmpty( $url );
+
+ // Parse the query string.
+ $parsed_url = wp_parse_url( $url );
+ $this->assertStringContainsString( 'options=%7B%22enabled%22%3Atrue%2C%22count%22%3A5%2C%22name%22%3A%22test%22%7D', $url );
+
+ parse_str( $parsed_url['query'], $query_params );
+ // Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+ foreach ( $query_params as $key => $value ) {
+ $this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+ }
+ }
+
+ /**
+ * Test that empty arrays are converted to empty strings.
+ */
+ public function test_array_property_empty_array() {
+ $event_props = array(
+ '_en' => 'test_event',
+ '_ts' => WC_Tracks_Client::build_timestamp(),
+ 'empty_arr' => array(),
+ 'other_prop' => 'value',
+ );
+
+ $event = new \WC_Tracks_Event( $event_props );
+ $url = $event->build_pixel_url();
+
+ // Verify URL was built successfully.
+ $this->assertNotEmpty( $url );
+
+ // Parse the query string.
+ $parsed_url = wp_parse_url( $url );
+ parse_str( $parsed_url['query'], $query_params );
+
+ // Verify empty array became empty string.
+ $this->assertArrayHasKey( 'empty_arr', $query_params );
+ $this->assertEquals( '', $query_params['empty_arr'] );
+ }
+
+ /**
+ * Test that nested arrays (indexed with associative) are handled.
+ */
+ public function test_array_property_nested_array() {
+ $event_props = array(
+ '_en' => 'test_event',
+ '_ts' => WC_Tracks_Client::build_timestamp(),
+ 'items' => array(
+ array(
+ 'id' => 1,
+ 'name' => 'Item 1',
+ ),
+ array(
+ 'id' => 2,
+ 'name' => 'Item 2',
+ ),
+ ),
+ );
+
+ $event = new \WC_Tracks_Event( $event_props );
+ $url = $event->build_pixel_url();
+
+ // Verify URL was built successfully.
+ $this->assertNotEmpty( $url );
+ $this->assertStringContainsString( 'items=%5B%7B%22id%22%3A1%2C%22name%22%3A%22Item+1%22%7D%2C%7B%22id%22%3A2%2C%22name%22%3A%22Item+2%22%7D%5D', $url );
+
+ // Parse the query string.
+ $parsed_url = wp_parse_url( $url );
+ parse_str( $parsed_url['query'], $query_params );
+ // Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+ foreach ( $query_params as $key => $value ) {
+ $this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+ }
+ }
+
+ /**
+ * Test array with values containing commas.
+ */
+ public function test_array_property_with_commas() {
+ $event_props = array(
+ '_en' => 'test_event',
+ '_ts' => WC_Tracks_Client::build_timestamp(),
+ 'tags' => array( 'tag1', 'tag,with,commas', 'tag3' ),
+ );
+
+ $event = new \WC_Tracks_Event( $event_props );
+ $url = $event->build_pixel_url();
+
+ // Verify URL was built successfully.
+ $this->assertNotEmpty( $url );
+ $this->assertStringContainsString( 'tags=tag1%2Ctag%2Cwith%2Ccommas%2Ctag3', $url );
+
+ // Parse the query string.
+ $parsed_url = wp_parse_url( $url );
+ parse_str( $parsed_url['query'], $query_params );
+ // Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+ foreach ( $query_params as $key => $value ) {
+ $this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+ }
+ }
+
+ /**
+ * Test that all property names remain valid after array conversion.
+ */
+ public function test_array_property_names_remain_valid() {
+ $event_props = array(
+ '_en' => 'test_event',
+ '_ts' => WC_Tracks_Client::build_timestamp(),
+ 'simple_indexed_array' => array( 'a', 'b', 'c' ),
+ 'complex_associative' => array( 'key' => 'value' ),
+ 'empty_array' => array(),
+ );
+
+ $event = new \WC_Tracks_Event( $event_props );
+ $url = $event->build_pixel_url();
+
+ // Verify URL was built successfully.
+ $this->assertNotEmpty( $url );
+
+ // Parse the query string directly (without parse_str to avoid bracket notation conversion).
+ $parsed_url = wp_parse_url( $url );
+ $query_string = $parsed_url['query'];
+
+ // Extract all parameter names from the query string.
+ preg_match_all( '/([^=&]+)=/', $query_string, $matches );
+ $param_names = $matches[1];
+
+ // Verify all property names are valid (match regex).
+ foreach ( $param_names as $param_name ) {
+ // URL decode the parameter name to get the actual property name.
+ $decoded_name = urldecode( $param_name );
+ $this->assertMatchesRegularExpression(
+ '/^[a-z_][a-z0-9_]*$/',
+ $decoded_name,
+ "Property name '$decoded_name' should match the valid naming pattern"
+ );
+ }
+ }
+
+ /**
+ * Test multiple array properties in the same event.
+ */
+ public function test_multiple_array_properties() {
+ $event_props = array(
+ '_en' => 'test_event',
+ '_ts' => WC_Tracks_Client::build_timestamp(),
+ 'blocks' => array( 'block-a', 'block-b' ),
+ 'settings' => array( 'enabled' => true ),
+ 'empty' => array(),
+ 'normal' => 'regular_value',
+ );
+
+ $event = new \WC_Tracks_Event( $event_props );
+ $url = $event->build_pixel_url();
+
+ // Verify URL was built successfully.
+ $this->assertNotEmpty( $url );
+ $this->assertStringContainsString( 'blocks=block-a%2Cblock-b', $url );
+ $this->assertStringContainsString( 'settings=%7B%22enabled%22%3Atrue%7D', $url );
+ $this->assertStringContainsString( 'empty=', $url );
+ $this->assertStringContainsString( 'normal=regular_value', $url );
+
+ // Parse the query string.
+ $parsed_url = wp_parse_url( $url );
+ parse_str( $parsed_url['query'], $query_params );
+ // Verify no bracket notation in URL (parse_str would create array values if brackets were present).
+ foreach ( $query_params as $key => $value ) {
+ $this->assertIsNotArray( $value, "Property '$key' should not be an array after parse_str (indicates bracket notation in URL)" );
+ }
+ }
}