Commit fd83e63f023 for woocommerce

commit fd83e63f0234ba7fef2170c3f37cdea054fd4ce1
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Thu Sep 3 16:45:33 2026 +0800

    Bound what the analytics tracking proxy endpoint accepts (#68314)

    * fix(analytics): bound what the tracking proxy endpoint accepts

    The endpoint is unauthenticated and every event becomes an outbound pixel
    request, so the per-axis limits multiply: at their limits one event still built
    a 512KB pixel URL and one request 24.5MB of outbound traffic.

    Cap events per request, properties per event, array members, value and name
    lengths, and add an encoded payload budget per event plus a ceiling on the
    finished pixel URL as the backstop. Cap the session values read from the
    client-writable cookie too, since first-party events never meet the client caps
    and would otherwise be dropped by that ceiling without a signal.

    * fix(analytics): guard the speed module's read of the client-bound constant

    process_proxy_request() reads MAX_CLIENT_EVENTS_PER_REQUEST off the package,
    but load_autoloader() only checked for record_client_event(). The autoloader
    resolves the highest version across active plugins, so a module written by
    this version can run against a package that has the method and not the
    constant.

    Reading it then throws, and handle_proxy_request() catches it into a 500 with
    no retry, rather than returning false and letting the request fall through to
    the REST route the way the existing guard does.

    Same check, same fallback, plus an assertion in the template sync test.

    * fix(analytics): keep events instead of dropping them at the bounds

    Review of the bounds found four places where a caller loses data it did not
    have to lose.

    The value cap was set at 200 characters, close to real values rather than well
    above them: an ad-click landing URL carrying gclid and fbclid runs to 221 and
    was truncated, destroying the campaign attribution the event exists to record.
    Raise it to 1000. MAX_CLIENT_PAYLOAD_LENGTH is what bounds the event, and it
    applies whatever the value cap is, so the worst case does not move.

    Values the server derives from request headers were not capped at all, so one
    long Referer -- which reaches the pixel twice, as _dr and _via_ref -- pushed the
    URL past MAX_PIXEL_URL_LENGTH and cost the whole event. The client fires with
    sendBeacon and discards the response, so the loss was invisible at both ends.
    Cap them like everything else: a long header now costs its own tail.

    is_engaged was read straight out of the client-writable session cookie with no
    type check, so a nested array reached implode() in get_properties() and wrote an
    "Array to string conversion" warning from an unauthenticated request. Its two
    neighbours were already capped; it was the one that was missed.

    landing_page carries a JSON breadcrumb trail, and capping it as a plain string
    cut mid-token and produced invalid JSON. Drop whole trailing entries instead.

    Two consequences of raising the cap, both fixed here rather than left:

    An over-budget value is now trimmed rather than dropped, matching what arrays
    already did. A value at the character cap can outweigh the byte budget on its
    own, since a CJK character costs nine bytes percent-encoded, and dropping it
    would lose a product name to an encoding difference.

    The budget is spent cheapest-first, so a long value costs its own tail and not
    every property after it. In source order a single large pn would take pi, pp and
    pt down with it.

    Also corrects three comments: CJK costs 9x in a URL rather than 3x, the pixel
    ceiling serves ClickHouse as well as Tracks, and the proxy endpoint does check
    the return value the comment said nothing checked.

    * test(analytics): execute the speed module's constant guard

    The guard had no runtime coverage: the assertion only matched its text, so a
    typo in the constant name would leave load_autoloader() always returning false
    and the speed module silently never serving.

    Pull the guarded name out of the template and call defined() on it. This also
    settles a review question with running code: the leading backslash does not
    break defined(), on 7.4 through 8.4.

    * docs: simplify analytics proxy documentation

diff --git a/packages/php/woocommerce-analytics/README.md b/packages/php/woocommerce-analytics/README.md
index 880da3ef33e..cb7bc8a9905 100644
--- a/packages/php/woocommerce-analytics/README.md
+++ b/packages/php/woocommerce-analytics/README.md
@@ -75,12 +75,27 @@ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__retu
 This registers the unauthenticated `POST /wp-json/woocommerce-analytics/v1/track`
 endpoint. Sites without proxy tracking enabled do not get it.

-Events arriving through it are untrusted: server-derived properties are replaced
-with the server's own values. The set is
-`WC_Analytics_Tracking::get_reserved_property_names()`, which includes generic
-names like `url`, `device` and `timezone` — rename event properties that would
-collide. `_lg`, `_dl` and `_dr` stay the client's, because they describe the page
-the event happened on rather than the `/track` request.
+Events arriving through it are untrusted. Server-derived properties replace
+client values; the reserved set is
+`WC_Analytics_Tracking::get_reserved_property_names()`. `_lg`, `_dl`, and
+`_dr` are client values because they describe the page, not the `/track` request.
+
+Input is limited. Long values are truncated with `…`, and the payload budget
+keeps the cheapest properties first. The same value limit applies to
+request-derived values and the session cookie.
+
+| Limit                     | Value |
+| ------------------------- | ----- |
+| Events per request        | 50    |
+| Properties per event      | 50    |
+| Members per array value   | 50    |
+| Characters per value      | 1000  |
+| Characters per name       | 100   |
+| Encoded payload per event | 4096  |
+| Pixel URL bytes           | 8192  |
+
+Invalid event names and oversized pixel URLs return an error. Events beyond the
+batch limit are ignored.

 **The filter must resolve to the same value for every request on a site.** One
 that varies by cohort, percentage or geo makes cached pages disagree with what
diff --git a/packages/php/woocommerce-analytics/changelog/wooa7s-client-input-bounds b/packages/php/woocommerce-analytics/changelog/wooa7s-client-input-bounds
new file mode 100644
index 00000000000..37f1c0672ab
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/wooa7s-client-input-bounds
@@ -0,0 +1,4 @@
+Significance: minor
+Type: security
+
+Bound what the unauthenticated tracking proxy endpoint accepts: events per request, properties per event, array members, value and name lengths, an encoded payload budget per event, and a ceiling on the pixel URL that is fired.
diff --git a/packages/php/woocommerce-analytics/src/API/class-wc-analytics-tracking-proxy.php b/packages/php/woocommerce-analytics/src/API/class-wc-analytics-tracking-proxy.php
index fc9d4586aaf..d745f98b2cc 100644
--- a/packages/php/woocommerce-analytics/src/API/class-wc-analytics-tracking-proxy.php
+++ b/packages/php/woocommerce-analytics/src/API/class-wc-analytics-tracking-proxy.php
@@ -77,6 +77,11 @@ class WC_Analytics_Tracking_Proxy extends \WC_REST_Controller {
 			$events = array( $events );
 		}

+		// Limit unauthenticated callers to a bounded number of pixel requests.
+		if ( count( $events ) > WC_Analytics_Tracking::MAX_CLIENT_EVENTS_PER_REQUEST ) {
+			$events = array_slice( $events, 0, WC_Analytics_Tracking::MAX_CLIENT_EVENTS_PER_REQUEST, true );
+		}
+
 		$results    = array();
 		$has_errors = false;

diff --git a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
index 636ee4affd4..0cd35eb0eef 100644
--- a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
+++ b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
@@ -58,6 +58,81 @@ class WC_Analytics_Tracking {
 	 */
 	const RESERVED_IDENTITY_PROPERTIES = array( '_ui', '_ut', '_en', '_ts', 'browser_type' );

+	/**
+	 * Maximum number of events a single client request may record.
+	 *
+	 * Prevents the unauthenticated endpoint from creating unbounded pixel requests.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @var int
+	 */
+	const MAX_CLIENT_EVENTS_PER_REQUEST = 50;
+
+	/**
+	 * Maximum number of properties a client may set on one event.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @var int
+	 */
+	const MAX_CLIENT_PROPERTIES_PER_EVENT = 50;
+
+	/**
+	 * Maximum length of a single property value bound for the pixel URL.
+	 *
+	 * The payload limit still caps the full event, while this preserves attribution URLs.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @var int
+	 */
+	const MAX_CLIENT_PROPERTY_LENGTH = 1000;
+
+	/**
+	 * Maximum length of a client-supplied event or property name.
+	 *
+	 * `Pixel_Builder` validates characters but not length.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @var int
+	 */
+	const MAX_CLIENT_NAME_LENGTH = 100;
+
+	/**
+	 * Maximum number of members in a client-supplied array value.
+	 *
+	 * Avoids excessive work while fitting an array into the payload budget.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @var int
+	 */
+	const MAX_CLIENT_ARRAY_MEMBERS = 50;
+
+	/**
+	 * Maximum total length of one event's client-supplied properties.
+	 *
+	 * Counts percent-encoded URL bytes, which can exceed a value's character count.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @var int
+	 */
+	const MAX_CLIENT_PAYLOAD_LENGTH = 4096;
+
+	/**
+	 * Maximum length of a pixel URL this package will fire.
+	 *
+	 * This also bounds properties added after client properties are sanitized.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @var int
+	 */
+	const MAX_PIXEL_URL_LENGTH = 8192;
+
 	/**
 	 * Event queue.
 	 *
@@ -108,8 +183,9 @@ class WC_Analytics_Tracking {
 	 * @param string $event_name The name of the event.
 	 * @param array  $event_properties Custom properties to send with the event.
 	 * @param bool   $is_client_supplied Whether $event_properties came from an untrusted
-	 *                                   client. Reserved property names are stripped when
-	 *                                   true. Defaults to false for server-side callers.
+	 *                                   client. Reserved property names are stripped and the
+	 *                                   rest are bounded when true. Defaults to false for
+	 *                                   server-side callers.
 	 *
 	 * @return bool|WP_Error True on emit or deliberate skip (no consent, bot UA, or
 	 *                       cookie-less context); WP_Error for an unusable client
@@ -132,13 +208,12 @@ class WC_Analytics_Tracking {
 		}

 		if ( $is_client_supplied ) {
-			// An error rather than a silent skip: an unusable name produces no pixel,
-			// and reporting success for it is what makes the loss invisible.
+			// Report invalid names because they cannot produce an event.
 			if ( ! self::is_valid_client_name( $event_name ) ) {
-				return new WP_Error( 'invalid_event_name', 'the event name is empty or not a string', array( 'status' => 400 ) );
+				return new WP_Error( 'invalid_event_name', 'the event name is empty, too long, or not a string', 400 );
 			}

-			$event_properties = self::strip_reserved_properties( $event_properties );
+			$event_properties = self::sanitize_client_properties( $event_properties );
 		}

 		$prefixed_event_name = self::PREFIX . $event_name;
@@ -255,6 +330,26 @@ class WC_Analytics_Tracking {
 			return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 );
 		}

+		if ( strlen( $pixel_url ) > self::MAX_PIXEL_URL_LENGTH ) {
+			// The proxy endpoint reports this error back to its caller, but no
+			// first-party call site checks the return value, so for those events the
+			// log line is the only signal that one was dropped.
+			$error_message = sprintf(
+				'WooCommerce Analytics: dropped a %d byte pixel, over the %d byte limit.',
+				strlen( $pixel_url ),
+				self::MAX_PIXEL_URL_LENGTH
+			);
+			if ( function_exists( 'wc_get_logger' ) ) {
+				wc_get_logger()->warning( $error_message, array( 'source' => 'woocommerce-analytics' ) );
+			} else {
+				// Fallback for MU-plugin stage when WooCommerce logger is not available.
+				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+				error_log( $error_message );
+			}
+
+			return new WP_Error( 'pixel_too_long', 'tracks pixel URL exceeds the maximum length', 400 );
+		}
+
 		// Check if batching is supported.
 		$can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
 			|| ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
@@ -334,10 +429,11 @@ class WC_Analytics_Tracking {
 	private static function get_session_properties() {
 		$session_details = self::get_session_details();

+		// The client-writable cookie also affects first-party events.
 		return array(
-			'session_id'   => $session_details['session_id'] ?? null,
-			'landing_page' => $session_details['landing_page'] ?? null,
-			'is_engaged'   => $session_details['is_engaged'] ?? null,
+			'session_id'   => self::cap_property_value( $session_details['session_id'] ?? null ),
+			'landing_page' => self::cap_json_list_value( $session_details['landing_page'] ?? null ),
+			'is_engaged'   => self::cap_property_value( $session_details['is_engaged'] ?? null ),
 		);
 	}

@@ -433,26 +529,8 @@ class WC_Analytics_Tracking {

 		$all_properties = array_merge( $properties, $required_properties );

-		// Convert array values to a comma-separated string and URL-encode them to ensure compatibility with JavaScript's encodeURIComponent() for pixel URL transmission.
 		foreach ( $all_properties as $key => $value ) {
-			if ( ! is_array( $value ) ) {
-				continue;
-			}
-
-			if ( empty( $value ) ) {
-				$all_properties[ $key ] = '';
-				continue;
-			}
-
-			$is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
-			if ( $is_indexed_array ) {
-				$value_string           = implode( ',', $value );
-				$all_properties[ $key ] = rawurlencode( $value_string );
-				continue;
-			}
-
-			// Serialize non-indexed arrays to JSON strings.
-			$all_properties[ $key ] = wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
+			$all_properties[ $key ] = self::flatten_property_value( $value );
 		}

 		return $all_properties;
@@ -512,6 +590,78 @@ class WC_Analytics_Tracking {
 		);
 	}

+	/**
+	 * Strip and bound a client-supplied property array.
+	 *
+	 * Keep rejected properties silent so the unauthenticated endpoint cannot expose its limits.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param array $event_properties Client-supplied properties.
+	 * @return array Sanitized properties.
+	 */
+	public static function sanitize_client_properties( $event_properties ) {
+		$event_properties = self::strip_reserved_properties( $event_properties );
+
+		if ( count( $event_properties ) > self::MAX_CLIENT_PROPERTIES_PER_EVENT ) {
+			$event_properties = array_slice( $event_properties, 0, self::MAX_CLIENT_PROPERTIES_PER_EVENT, true );
+		}
+
+		$values = array();
+		$costs  = array();
+
+		foreach ( $event_properties as $key => $value ) {
+			// Dropped, not truncated: two long names could truncate to the same key.
+			if ( ! self::is_valid_client_name( $key ) || ! Pixel_Builder::prop_name_is_valid( $key ) ) {
+				continue;
+			}
+
+			// Arrays are flattened later by get_properties(); bound their members too.
+			if ( is_array( $value ) ) {
+				$value = array_map(
+					array( __CLASS__, 'cap_property_value' ),
+					array_slice( $value, 0, self::MAX_CLIENT_ARRAY_MEMBERS, true )
+				);
+			} else {
+				$value = self::cap_property_value( $value );
+			}
+
+			$values[ $key ] = $value;
+			$costs[ $key ]  = strlen( $key ) + self::measure_client_value( $value );
+		}
+
+		// Preserve more properties by fitting the cheapest values first.
+		asort( $costs );
+
+		$budget = self::MAX_CLIENT_PAYLOAD_LENGTH;
+		$kept   = array();
+
+		foreach ( $costs as $key => $cost ) {
+			$value = $values[ $key ];
+
+			// Trim values to keep them when their encoded form exceeds the remaining budget.
+			if ( $cost > $budget ) {
+				$room = $budget - strlen( $key );
+
+				$value = is_array( $value )
+					? self::fit_client_array( $value, $room )
+					: self::fit_client_string( (string) $value, $room );
+
+				if ( array() === $value || '' === $value ) {
+					continue;
+				}
+
+				$cost = strlen( $key ) + self::measure_client_value( $value );
+			}
+
+			$budget      -= $cost;
+			$kept[ $key ] = $value;
+		}
+
+		// Back into the order the caller sent, so the pixel is not reordered by cost.
+		return array_replace( array_intersect_key( $values, $kept ), $kept );
+	}
+
 	/**
 	 * Whether a client-supplied event or property name is usable.
 	 *
@@ -521,10 +671,179 @@ class WC_Analytics_Tracking {
 	 * @since 0.18.0
 	 *
 	 * @param mixed $name Client-supplied name.
-	 * @return bool True when the name is a non-empty string.
+	 * @return bool True when the name is a non-empty string within the length bound.
 	 */
 	private static function is_valid_client_name( $name ) {
-		return is_string( $name ) && '' !== $name;
+		return is_string( $name )
+			&& '' !== $name
+			&& mb_strlen( $name ) <= self::MAX_CLIENT_NAME_LENGTH;
+	}
+
+	/**
+	 * Reduce one property value to the string that goes into the pixel URL.
+	 *
+	 * The payload budget uses this same conversion to measure array values accurately.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param mixed $value Property value.
+	 * @return mixed The scalar it serializes to; non-array values are returned as-is.
+	 */
+	private static function flatten_property_value( $value ) {
+		if ( ! is_array( $value ) ) {
+			return $value;
+		}
+
+		if ( empty( $value ) ) {
+			return '';
+		}
+
+		if ( array_keys( $value ) === range( 0, count( $value ) - 1 ) ) {
+			return rawurlencode( implode( ',', $value ) );
+		}
+
+		return wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
+	}
+
+	/**
+	 * Bytes one value contributes to the pixel URL.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param mixed $value Already-capped client value.
+	 * @return int Byte count after `flatten_property_value()` and the encoding
+	 *             `http_build_query()` applies on top of it.
+	 */
+	private static function measure_client_value( $value ) {
+		// Match http_build_query()'s RFC1738 encoding.
+		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode -- Deliberate: mirrors http_build_query()'s RFC1738 encoding so the budget measures the bytes the finished URL carries.
+		return strlen( urlencode( (string) self::flatten_property_value( $value ) ) );
+	}
+
+	/**
+	 * Trim a string value until it fits the remaining budget.
+	 *
+	 * Uses binary search because each candidate must be encoded again.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param string $value Already-capped value.
+	 * @param int    $budget Bytes still available for this value.
+	 * @return string The longest prefix that fits, with an ellipsis; empty when
+	 *                even one character does not.
+	 */
+	private static function fit_client_string( $value, $budget ) {
+		if ( $budget <= 0 ) {
+			return '';
+		}
+
+		$low  = 0;
+		$high = mb_strlen( $value );
+
+		while ( $low < $high ) {
+			$mid = (int) ceil( ( $low + $high ) / 2 );
+
+			if ( self::measure_client_value( self::truncate_value( $value, $mid ) ) <= $budget ) {
+				$low = $mid;
+			} else {
+				$high = $mid - 1;
+			}
+		}
+
+		return self::truncate_value( $value, $low );
+	}
+
+	/**
+	 * Drop trailing members until an array value fits the remaining budget.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param array $members Already-capped members.
+	 * @param int   $budget Bytes still available for this value.
+	 * @return array Members that fit; empty when even one does not.
+	 */
+	private static function fit_client_array( $members, $budget ) {
+		while ( ! empty( $members ) && self::measure_client_value( $members ) > $budget ) {
+			array_pop( $members );
+		}
+
+		return $members;
+	}
+
+	/**
+	 * Bound a value that carries a JSON list, without invalidating the JSON.
+	 *
+	 * Preserve valid JSON by removing trailing list entries instead of cutting text.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param mixed $value Caller-influenced value.
+	 * @return mixed Bounded value, still valid JSON when it arrived as JSON.
+	 */
+	private static function cap_json_list_value( $value ) {
+		if ( ! is_string( $value ) || mb_strlen( $value ) <= self::MAX_CLIENT_PROPERTY_LENGTH ) {
+			return self::cap_property_value( $value );
+		}
+
+		$decoded = json_decode( $value, true );
+		if ( ! is_array( $decoded ) ) {
+			return self::cap_property_value( $value );
+		}
+
+		while ( ! empty( $decoded ) ) {
+			$encoded = wp_json_encode( $decoded );
+
+			if ( is_string( $encoded ) && mb_strlen( $encoded ) <= self::MAX_CLIENT_PROPERTY_LENGTH ) {
+				return $encoded;
+			}
+
+			array_pop( $decoded );
+		}
+
+		return '[]';
+	}
+
+	/**
+	 * Bound one value on its way to the pixel URL.
+	 *
+	 * Arrays and objects become empty strings to avoid warnings during flattening.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param mixed $value Caller-influenced value.
+	 * @return mixed Bounded value.
+	 */
+	private static function cap_property_value( $value ) {
+		if ( is_array( $value ) || is_object( $value ) ) {
+			return '';
+		}
+
+		if ( ! is_string( $value ) ) {
+			return $value;
+		}
+
+		if ( mb_strlen( $value ) <= self::MAX_CLIENT_PROPERTY_LENGTH ) {
+			return $value;
+		}
+
+		return self::truncate_value( $value, self::MAX_CLIENT_PROPERTY_LENGTH );
+	}
+
+	/**
+	 * Cut a value to a character count, marking that it was cut.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param string $value  Value to cut.
+	 * @param int    $length Characters the result may occupy, ellipsis included.
+	 * @return string The cut value, or an empty string when nothing fits.
+	 */
+	private static function truncate_value( $value, $length ) {
+		if ( $length <= 0 ) {
+			return '';
+		}
+
+		return mb_substr( $value, 0, $length - 1 ) . '…';
 	}

 	/**
@@ -584,6 +903,14 @@ class WC_Analytics_Tracking {
 		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 		$data['_via_ref'] = isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';

+		// Headers are caller-supplied, and the referer lands here twice. Uncapped, one
+		// long Referer pushes the finished URL past MAX_PIXEL_URL_LENGTH and costs the
+		// whole event; capped, it costs the tail of one value. `_lg` is already bounded
+		// above and `_via_ip` is validated by get_user_ip_address().
+		foreach ( array( '_via_ua', '_dr', '_dl', '_via_ref' ) as $key ) {
+			$data[ $key ] = self::cap_property_value( $data[ $key ] );
+		}
+
 		return $data;
 	}

diff --git a/packages/php/woocommerce-analytics/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php b/packages/php/woocommerce-analytics/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php
index 6d248384ca9..1dfc7c059b8 100644
--- a/packages/php/woocommerce-analytics/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php
+++ b/packages/php/woocommerce-analytics/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php
@@ -128,6 +128,12 @@ class WooCommerceAnalyticsProxySpeed {
 			return false;
 		}

+		// Avoid a 500 when an older package lacks the bound constant.
+		if ( ! defined( '\Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::MAX_CLIENT_EVENTS_PER_REQUEST' ) ) {
+			error_log( 'WooCommerce Analytics Proxy Speed Module: the loaded WC_Analytics_Tracking predates the client input bounds.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+			return false;
+		}
+
 		return true;
 	}

@@ -199,6 +205,12 @@ class WooCommerceAnalyticsProxySpeed {
 			$events = array( $events );
 		}

+		// Use the same batch limit as the REST controller.
+		$max_events = \Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::MAX_CLIENT_EVENTS_PER_REQUEST;
+		if ( count( $events ) > $max_events ) {
+			$events = array_slice( $events, 0, $max_events, true );
+		}
+
 		$results    = array();
 		$has_errors = false;

diff --git a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Proxy_Test.php b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Proxy_Test.php
index 08b96781db2..849bfaa26d8 100644
--- a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Proxy_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Proxy_Test.php
@@ -280,4 +280,36 @@ class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
 		$this->assertSame( '2', $props['pi'] ?? null );
 	}

+	/**
+	 * Every event becomes an outbound pixel request, so an unauthenticated caller
+	 * must not be able to fan out an unbounded batch.
+	 */
+	public function test_batch_size_is_capped(): void {
+		$_COOKIE['tk_ai']          = 'test-visitor-id-1234567890ab';
+		$_SERVER['REQUEST_METHOD'] = 'POST';
+		$_SERVER['REQUEST_URI']    = '/?rest_route=/woocommerce-analytics/v1/track';
+
+		add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+		Woocommerce_Analytics::register_rest_routes();
+
+		$events = array();
+		for ( $i = 0; $i < WC_Analytics_Tracking::MAX_CLIENT_EVENTS_PER_REQUEST + 10; $i++ ) {
+			$events[] = array(
+				'event_name' => 'add_to_cart',
+				'properties' => array( 'pi' => $i ),
+			);
+		}
+
+		$request = new \WP_REST_Request( 'POST', self::ROUTE );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( $events ) );
+
+		rest_do_request( $request );
+
+		$this->assertCount(
+			WC_Analytics_Tracking::MAX_CLIENT_EVENTS_PER_REQUEST,
+			$this->get_pixel_batch_queue(),
+			'The batch must be truncated to MAX_CLIENT_EVENTS_PER_REQUEST.'
+		);
+	}
 }
diff --git a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
index 6ef7c360025..b314cb70a4a 100644
--- a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
@@ -50,7 +50,7 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
 	 */
 	public function tear_down(): void {
 		$_SERVER = $this->server_snapshot;
-		unset( $_COOKIE['tk_ai'] );
+		unset( $_COOKIE['tk_ai'], $_COOKIE['woocommerceanalytics_session'] );
 		$this->reset_reserved_property_names();
 		$this->reset_pixel_batch_queue();
 		$this->reset_cached_ip();
@@ -365,9 +365,7 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
 	}

 	/**
-	 * The third argument tells a callback whether the properties it is looking
-	 * at came from an untrusted client, which is the only way it can know to
-	 * assign unconditionally.
+	 * Pass whether properties came from an untrusted client to filter callbacks.
 	 */
 	public function test_filter_receives_the_client_supplied_flag(): void {
 		$seen     = array();
@@ -392,12 +390,7 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
 	}

 	/**
-	 * A callback that defers to an existing value hands a *reserved* property
-	 * straight back to the client that supplied it. The strip runs before the
-	 * filter, so it cannot see this; get_properties() re-asserts the server's
-	 * values afterwards. Contrast with
-	 * test_filter_callback_deferring_to_an_existing_value_loses_to_the_client(),
-	 * which covers a name the filter invents — still the client's to win.
+	 * Reassert server-owned values after filters run on client properties.
 	 */
 	public function test_filter_callback_cannot_hand_a_reserved_property_back_to_the_client(): void {
 		update_option( 'woocommerce_store_id', 'real-store-id' );
@@ -420,9 +413,7 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
 	}

 	/**
-	 * The trusted path must keep its escape hatch: a server-side caller can still
-	 * set a property that collides with a common one, and the post-filter
-	 * re-assertion must not take that away.
+	 * Allow trusted callers to override common properties.
 	 */
 	public function test_reserved_properties_are_not_re_asserted_for_trusted_callers(): void {
 		update_option( 'woocommerce_store_id', 'real-store-id' );
@@ -437,8 +428,7 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
 	}

 	/**
-	 * `_ts` is in $required_properties and in RESERVED_IDENTITY_PROPERTIES, so a
-	 * client cannot forge the event timestamp at either layer.
+	 * Use the server timestamp for client events.
 	 */
 	public function test_client_cannot_forge_the_event_timestamp(): void {
 		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
@@ -457,9 +447,310 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
 	}

 	/**
-	 * A JSON array survives the consumers' truthiness check and reaches
-	 * `PREFIX . $event_name`, where PHP writes a warning to the log on an
-	 * unauthenticated request. `failOnWarning` makes that warning fail the test.
+	 * Truncate oversized client values.
+	 */
+	public function test_client_property_values_are_capped(): void {
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties(
+			array(
+				'pn'    => str_repeat( 'a', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH + 100 ),
+				'short' => 'kept',
+			)
+		);
+
+		$this->assertSame( WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH, mb_strlen( $sanitized['pn'] ) );
+		$this->assertStringEndsWith( '…', $sanitized['pn'] );
+		$this->assertSame( 'kept', $sanitized['short'], 'Values within the limit must be untouched.' );
+	}
+
+	/**
+	 * Limit client properties per event.
+	 */
+	public function test_client_property_count_is_capped(): void {
+		$properties = array();
+		for ( $i = 0; $i < WC_Analytics_Tracking::MAX_CLIENT_PROPERTIES_PER_EVENT + 25; $i++ ) {
+			$properties[ 'p' . $i ] = $i;
+		}
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( $properties );
+
+		$this->assertCount( WC_Analytics_Tracking::MAX_CLIENT_PROPERTIES_PER_EVENT, $sanitized );
+	}
+
+	/**
+	 * Prevent nested arrays from generating warnings while flattening values.
+	 */
+	public function test_nested_client_arrays_do_not_reach_the_flattening_step(): void {
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties(
+			array( 'foo' => array( array( 1, 2 ), 'ok' ) )
+		);
+
+		$this->assertSame( array( '', 'ok' ), $sanitized['foo'] );
+	}
+
+	/**
+	 * Limit client array members before flattening them.
+	 */
+	public function test_client_array_member_count_is_capped(): void {
+		$members = array_fill( 0, WC_Analytics_Tracking::MAX_CLIENT_ARRAY_MEMBERS + 25, 'abcdefghij' );
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( array( 'pc' => $members ) );
+
+		$this->assertCount( WC_Analytics_Tracking::MAX_CLIENT_ARRAY_MEMBERS, $sanitized['pc'] );
+
+		$props = WC_Analytics_Tracking::get_properties( 'woocommerceanalytics_product_view', $sanitized, true );
+
+		$this->assertLessThan(
+			2000,
+			strlen( $props['pc'] ),
+			'The flattened value is what reaches the pixel URL, so the cap must survive flattening.'
+		);
+	}
+
+	/**
+	 * Keep capped indexed arrays indexed so they continue to flatten correctly.
+	 */
+	public function test_capped_arrays_still_flatten_with_implode(): void {
+		$members = array_fill( 0, WC_Analytics_Tracking::MAX_CLIENT_ARRAY_MEMBERS + 5, 'a' );
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( array( 'pc' => $members ) );
+		$props     = WC_Analytics_Tracking::get_properties( 'woocommerceanalytics_product_view', $sanitized, true );
+
+		$this->assertSame(
+			rawurlencode( implode( ',', array_fill( 0, WC_Analytics_Tracking::MAX_CLIENT_ARRAY_MEMBERS, 'a' ) ) ),
+			$props['pc']
+		);
+	}
+
+	/**
+	 * Keep client payloads within the pixel URL limit.
+	 */
+	public function test_client_payload_total_is_capped( string $character = 'a' ): void {
+		$properties = array();
+		for ( $i = 0; $i < WC_Analytics_Tracking::MAX_CLIENT_PROPERTIES_PER_EVENT; $i++ ) {
+			$properties[ 'p' . $i ] = str_repeat( $character, WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH );
+		}
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( $properties );
+
+		$this->assertNotEmpty( $sanitized, 'The budget drops the tail, it does not empty the event.' );
+
+		// Verify the final URL, not just the budget constant.
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+		$props            = WC_Analytics_Tracking::get_properties( 'woocommerceanalytics_product_view', $sanitized, true );
+
+		$this->assertLessThanOrEqual(
+			WC_Analytics_Tracking::MAX_PIXEL_URL_LENGTH,
+			strlen( Pixel_Builder::build_tracks_url( $props ) )
+		);
+
+		unset( $_COOKIE['tk_ai'] );
+	}
+
+	/**
+	 * Measure encoded bytes rather than character counts.
+	 *
+	 * @dataProvider expensive_character_provider
+	 *
+	 * @param string $character One character whose encoded form is longer than itself.
+	 */
+	public function test_client_payload_budget_counts_encoded_bytes( string $character ): void {
+		$this->test_client_payload_total_is_capped( $character );
+	}
+
+	/**
+	 * Characters that cost more in the URL than they do in the payload.
+	 *
+	 * @return array<string, array{0: string}>
+	 */
+	public function expensive_character_provider(): array {
+		return array(
+			'percent' => array( '%' ),
+			'CJK'     => array( '漢' ),
+			'emoji'   => array( '😀' ),
+			'space'   => array( ' ' ),
+			'tilde'   => array( '~' ),
+		);
+	}
+
+	/**
+	 * Include associative-array keys when measuring payload size.
+	 */
+	public function test_associative_array_keys_are_charged_to_the_budget(): void {
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+
+		$properties = array();
+		for ( $p = 0; $p < WC_Analytics_Tracking::MAX_CLIENT_PROPERTIES_PER_EVENT; $p++ ) {
+			$members = array();
+			for ( $i = 0; $i < WC_Analytics_Tracking::MAX_CLIENT_ARRAY_MEMBERS; $i++ ) {
+				$members[ str_repeat( 'k', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH ) . $i ] = 'v';
+			}
+			$properties[ 'p' . $p ] = $members;
+		}
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( $properties );
+		$props     = WC_Analytics_Tracking::get_properties( 'woocommerceanalytics_product_view', $sanitized, true );
+
+		$this->assertLessThanOrEqual(
+			WC_Analytics_Tracking::MAX_PIXEL_URL_LENGTH,
+			strlen( Pixel_Builder::build_tracks_url( $props ) )
+		);
+
+		unset( $_COOKIE['tk_ai'] );
+	}
+
+	/**
+	 * Do not charge the budget for properties that are dropped.
+	 */
+	public function test_a_dropped_property_does_not_spend_the_budget(): void {
+		// Long names ensure this exercises each dropped key's cost.
+		$properties = array();
+		for ( $i = 0; $i < 40; $i++ ) {
+			$name                = str_repeat( 'k', WC_Analytics_Tracking::MAX_CLIENT_NAME_LENGTH - 12 ) . $i;
+			$properties[ $name ] = str_repeat( 'y', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH );
+		}
+		$properties['last'] = 'short';
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( $properties );
+
+		$this->assertSame( 'short', $sanitized['last'] ?? null, 'A property that fits must not be refused for a dropped one.' );
+	}
+
+	/**
+	 * Truncate multibyte values without splitting characters.
+	 *
+	 * @dataProvider multibyte_value_provider
+	 *
+	 * @param string $character One multibyte character.
+	 */
+	public function test_value_cap_counts_characters_not_bytes( string $character ): void {
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties(
+			array( 'pn' => str_repeat( $character, WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH + 50 ) )
+		);
+
+		$this->assertLessThan(
+			WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH + 50,
+			mb_strlen( $sanitized['pn'] ),
+			'An over-cap value must come back shorter.'
+		);
+		$this->assertStringEndsWith( '…', $sanitized['pn'] );
+		$this->assertSame( $sanitized['pn'], mb_convert_encoding( $sanitized['pn'], 'UTF-8', 'UTF-8' ), 'The cut must not split a character.' );
+	}
+
+	/**
+	 * Provide multibyte characters for truncation tests.
+	 *
+	 * @return array<string, array{0: string}>
+	 */
+	public function multibyte_value_provider(): array {
+		return array(
+			'CJK'    => array( '漢' ),
+			'emoji'  => array( '😀' ),
+			'accent' => array( 'é' ),
+		);
+	}
+
+	/**
+	 * Keep values at the length limit.
+	 */
+	public function test_a_value_at_the_length_limit_is_untouched(): void {
+		$exact = str_repeat( 'a', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH );
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( array( 'pn' => $exact ) );
+
+		$this->assertSame( $exact, $sanitized['pn'] );
+	}
+
+	/**
+	 * Keep oversized arrays by removing trailing members.
+	 */
+	public function test_oversized_arrays_lose_members_not_the_property(): void {
+		$members = array_fill( 0, WC_Analytics_Tracking::MAX_CLIENT_ARRAY_MEMBERS, str_repeat( 'a', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH ) );
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( array( 'pc' => $members ) );
+
+		$this->assertArrayHasKey( 'pc', $sanitized, 'The property must survive with fewer members.' );
+		$this->assertLessThan( count( $members ), count( $sanitized['pc'] ) );
+	}
+
+	/**
+	 * Do not queue pixel URLs that exceed the final URL limit.
+	 */
+	public function test_oversized_pixel_urls_are_not_fired(): void {
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+		$this->reset_pixel_batch_queue();
+
+		$result = WC_Analytics_Tracking::record_event(
+			'add_to_cart',
+			array( 'pn' => str_repeat( 'a', WC_Analytics_Tracking::MAX_PIXEL_URL_LENGTH * 2 ) )
+		);
+
+		$this->assertTrue( is_wp_error( $result ) );
+		$this->assertSame( 'pixel_too_long', $result->get_error_code() );
+		$this->assertSame( array(), $this->get_pixel_batch_queue(), 'Nothing may be queued.' );
+
+		$this->reset_pixel_batch_queue();
+		unset( $_COOKIE['tk_ai'] );
+	}
+
+	/**
+	 * Keep typical client payloads unchanged.
+	 */
+	public function test_a_realistic_client_payload_is_not_capped(): void {
+		$properties = array(
+			'pi'  => 731,
+			'pn'  => 'Some Reasonably Long Product Name With Words',
+			'pt'  => 'simple',
+			'pc'  => array( 'Clothing', 'Shirts', 'Sale' ),
+			'pp'  => 115.81,
+			'_lg' => 'en-GB',
+			'_dl' => 'https://example.com/product/some-reasonably-long-product-slug/?utm_source=x',
+			'_dr' => 'https://example.com/shop/page/3/',
+		);
+
+		$this->assertSame( $properties, WC_Analytics_Tracking::sanitize_client_properties( $properties ) );
+	}
+
+	/**
+	 * Apply bounds when recording a client event.
+	 */
+	public function test_record_client_event_actually_applies_the_bounds(): void {
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+		$this->reset_pixel_batch_queue();
+
+		WC_Analytics_Tracking::record_client_event(
+			'add_to_cart',
+			array(
+				'pn'        => str_repeat( 'a', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH + 100 ),
+				'Uppercase' => 'dropped by the name check',
+			)
+		);
+
+		$props = $this->get_queued_pixel_props();
+
+		$this->assertSame( WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH, mb_strlen( $props['pn'] ?? '' ) );
+		$this->assertArrayNotHasKey( 'Uppercase', $props );
+
+		$this->reset_pixel_batch_queue();
+		unset( $_COOKIE['tk_ai'] );
+	}
+
+	/**
+	 * Preserve scalar value types.
+	 */
+	public function test_client_scalar_values_keep_their_type(): void {
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties(
+			array(
+				'pi' => 42,
+				'ch' => true,
+			)
+		);
+
+		$this->assertSame( 42, $sanitized['pi'] );
+		$this->assertTrue( $sanitized['ch'] );
+	}
+
+	/**
+	 * Reject unusable client event names.
 	 *
 	 * @dataProvider unusable_client_event_name_provider
 	 *
@@ -486,13 +777,148 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
 			'array'      => array( array( 'product_view' ) ),
 			'nested map' => array( array( 'name' => 'product_view' ) ),
 			'empty'      => array( '' ),
+			'oversized'  => array( str_repeat( 'a', WC_Analytics_Tracking::MAX_CLIENT_NAME_LENGTH + 1 ) ),
+		);
+	}
+
+	/**
+	 * Keep event names at the length limit.
+	 */
+	public function test_client_event_name_at_the_length_limit_is_recorded(): void {
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+		$this->reset_pixel_batch_queue();
+
+		$name = str_repeat( 'a', WC_Analytics_Tracking::MAX_CLIENT_NAME_LENGTH );
+
+		WC_Analytics_Tracking::record_client_event( $name, array() );
+
+		$props = $this->get_queued_pixel_props();
+
+		$this->assertSame( WC_Analytics_Tracking::PREFIX . $name, $props['_en'] ?? null );
+	}
+
+	/**
+	 * Drop invalid client property names.
+	 */
+	public function test_unusable_client_property_names_are_dropped(): void {
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties(
+			array(
+				str_repeat( 'a', WC_Analytics_Tracking::MAX_CLIENT_NAME_LENGTH + 1 ) => 'oversized',
+				'Uppercase' => 'bad charset',
+				'has space' => 'bad charset',
+				'pi'        => 42,
+				'_lg'       => 'en-GB',
+			)
+		);
+
+		$this->assertSame( array( 'pi' => 42, '_lg' => 'en-GB' ), $sanitized );
+	}
+
+	/**
+	 * Drop numeric client property names.
+	 */
+	public function test_numeric_client_property_names_are_dropped(): void {
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties(
+			array(
+				'0'  => 'dropped',
+				'pi' => 42,
+			)
+		);
+
+		$this->assertSame( array( 'pi' => 42 ), $sanitized );
+	}
+
+	/**
+	 * Do not generate warnings for non-scalar session values.
+	 */
+	public function test_a_non_scalar_session_value_writes_no_warning(): void {
+		$_COOKIE['tk_ai']                        = 'test-visitor-id-1234567890ab';
+		$_COOKIE['woocommerceanalytics_session'] = wp_slash(
+			(string) wp_json_encode( array( 'is_engaged' => array( array( 'nested' ) ) ) )
+		);
+
+		$warnings = array();
+		set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure
+			function ( $errno, $message ) use ( &$warnings ) {
+				$warnings[] = $message;
+				return true;
+			}
+		);
+		WC_Analytics_Tracking::record_client_event( 'product_view', array( 'pi' => 42 ) );
+		restore_error_handler();
+
+		$this->assertSame( array(), $warnings, 'A cookie value must not be able to write PHP warnings.' );
+	}
+
+	/**
+	 * Keep oversized landing-page trails valid JSON.
+	 */
+	public function test_an_oversized_landing_page_stays_valid_json(): void {
+		$trail = array_fill( 0, 200, 'Category' );
+
+		$_COOKIE['woocommerceanalytics_session'] = wp_slash(
+			(string) wp_json_encode( array( 'landing_page' => wp_json_encode( $trail ) ) )
 		);
+
+		$properties = WC_Analytics_Tracking::get_common_properties();
+		$decoded    = json_decode( $properties['landing_page'], true );
+
+		$this->assertLessThanOrEqual(
+			WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH,
+			mb_strlen( $properties['landing_page'] )
+		);
+		$this->assertIsArray( $decoded, 'A trimmed trail must still parse as JSON.' );
+		$this->assertNotEmpty( $decoded );
+		$this->assertSame( 'Category', $decoded[0], 'The leading entries are the ones kept.' );
+	}
+
+	/**
+	 * Trim long referrers without dropping the event.
+	 */
+	public function test_a_long_referer_costs_its_own_tail_not_the_event(): void {
+		$_COOKIE['tk_ai']        = 'test-visitor-id-1234567890ab';
+		$_SERVER['HTTP_REFERER'] = 'https://example.com/?q=' . str_repeat( 'a', 5000 );
+		$this->reset_pixel_batch_queue();
+
+		$result = WC_Analytics_Tracking::record_client_event( 'product_view', array( 'pi' => 42 ) );
+
+		$this->assertFalse( is_wp_error( $result ), 'A long request header must not cost the event.' );
+
+		$props = $this->get_queued_pixel_props();
+
+		$this->assertSame( '42', $props['pi'] ?? null, 'The event payload must survive intact.' );
+		$this->assertStringEndsWith( '…', $props['_dr'] ?? '', 'The referer is what gets trimmed.' );
+
+		$this->reset_pixel_batch_queue();
+	}
+
+	/**
+	 * Preserve common ad-click landing URLs.
+	 */
+	public function test_an_ad_click_landing_url_survives_untouched(): void {
+		$url = 'https://example.com/product-category/clothing/mens-shirts/?utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=Cj0KCQjw1viWBhD0ARIsAAM_oKnLQ8example1234567890abcdefghij&fbclid=IwAR2example1234567890abcdefghijklmnop';
+
+		$this->assertGreaterThan( 200, mb_strlen( $url ), 'A fixture under the old cap would prove nothing.' );
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( array( '_dl' => $url ) );
+
+		$this->assertSame( $url, $sanitized['_dl'] ?? null );
+	}
+
+	/**
+	 * Trim values that exceed the encoded payload budget.
+	 */
+	public function test_a_value_the_budget_cannot_fit_is_trimmed_not_dropped(): void {
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties(
+			array( 'pn' => str_repeat( '漢', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH ) )
+		);
+
+		$this->assertArrayHasKey( 'pn', $sanitized, 'An over-budget value must be trimmed, not dropped.' );
+		$this->assertStringEndsWith( '…', $sanitized['pn'] );
 	}

 	/**
-	 * The template must record through the untrusted-client entry point. Nothing
-	 * in the suite executes the template, so asserting on its source text is
-	 * crude, but it is the only thing standing behind that requirement.
+	 * Keep the MU-plugin template aligned with the package API.
 	 */
 	public function test_mu_plugin_template_stays_in_step_with_the_package(): void {
 		$template = file_get_contents(
@@ -509,5 +935,95 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
 			$template,
 			'The template must not call the trusted entry point.'
 		);
+
+		$this->assertStringContainsString(
+			'MAX_CLIENT_EVENTS_PER_REQUEST',
+			$template,
+			'The template must cap the batch with the same constant as the REST controller.'
+		);
+		$this->assertSame(
+			1,
+			preg_match( '/defined\( \'([^\']*::[^\']+)\' \)/', $template, $matches ),
+			'The template must check the constant exists before reading it, since the autoloader can resolve an older package.'
+		);
+		$this->assertTrue(
+			defined( $matches[1] ),
+			"The guarded name must resolve, or load_autoloader() always returns false and the module never serves. Reviewers read the leading backslash in {$matches[1]} as breaking defined(); it does not, on any PHP this package supports."
+		);
+	}
+
+	/**
+	 * Keep the documented bounds in sync with their constants.
+	 */
+	public function test_client_bounds_have_their_documented_values(): void {
+		$this->assertSame( 50, WC_Analytics_Tracking::MAX_CLIENT_EVENTS_PER_REQUEST );
+		$this->assertSame( 50, WC_Analytics_Tracking::MAX_CLIENT_PROPERTIES_PER_EVENT );
+		$this->assertSame( 50, WC_Analytics_Tracking::MAX_CLIENT_ARRAY_MEMBERS );
+		$this->assertSame( 1000, WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH );
+		$this->assertSame( 100, WC_Analytics_Tracking::MAX_CLIENT_NAME_LENGTH );
+		$this->assertSame( 4096, WC_Analytics_Tracking::MAX_CLIENT_PAYLOAD_LENGTH );
+		$this->assertSame( 8192, WC_Analytics_Tracking::MAX_PIXEL_URL_LENGTH );
+	}
+
+	/**
+	 * Keep the largest permitted payload below the fixed pixel URL limit.
+	 */
+	public function test_a_maximal_client_payload_stays_under_eight_kilobytes(): void {
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+
+		$properties = array();
+		for ( $i = 0; $i < WC_Analytics_Tracking::MAX_CLIENT_PROPERTIES_PER_EVENT; $i++ ) {
+			$key                = str_repeat( 'k', WC_Analytics_Tracking::MAX_CLIENT_NAME_LENGTH - 3 ) . sprintf( '%03d', $i );
+			$properties[ $key ] = str_repeat( '漢', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH );
+		}
+
+		$all = WC_Analytics_Tracking::get_properties(
+			WC_Analytics_Tracking::PREFIX . 'bounds_probe',
+			WC_Analytics_Tracking::sanitize_client_properties( $properties ),
+			true
+		);
+
+		$this->assertLessThanOrEqual(
+			8192,
+			strlen( Pixel_Builder::build_tracks_url( $all ) ),
+			'The per-axis caps must not multiply past the pixel URL ceiling.'
+		);
+
+		unset( $_COOKIE['tk_ai'] );
+	}
+
+	/**
+	 * Include property names in the payload budget.
+	 */
+	public function test_property_names_are_charged_to_the_payload_budget(): void {
+		$short = array();
+		$long  = array();
+		for ( $i = 0; $i < WC_Analytics_Tracking::MAX_CLIENT_PROPERTIES_PER_EVENT; $i++ ) {
+			$value                = str_repeat( 'v', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH );
+			$short[ 's' . $i ]    = $value;
+			$key                  = str_repeat( 'k', WC_Analytics_Tracking::MAX_CLIENT_NAME_LENGTH - 3 ) . sprintf( '%03d', $i );
+			$long[ $key ]         = $value;
+		}
+
+		$this->assertLessThan(
+			count( WC_Analytics_Tracking::sanitize_client_properties( $short ) ),
+			count( WC_Analytics_Tracking::sanitize_client_properties( $long ) ),
+			'Long property names must consume budget, or the cap under-counts the URL.'
+		);
+	}
+
+	/**
+	 * Drop arrays that cannot retain a member within the payload budget.
+	 */
+	public function test_an_array_that_cannot_fit_even_one_member_is_dropped(): void {
+		$properties = array();
+		for ( $i = 0; $i < 20; $i++ ) {
+			$properties[ 'p' . $i ] = str_repeat( 'v', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH );
+		}
+		$properties['pc'] = array( str_repeat( 'm', WC_Analytics_Tracking::MAX_CLIENT_PROPERTY_LENGTH ) );
+
+		$sanitized = WC_Analytics_Tracking::sanitize_client_properties( $properties );
+
+		$this->assertArrayNotHasKey( 'pc', $sanitized, 'An emptied array must be dropped, not sent as an empty value.' );
 	}
 }