Commit 0934663faf0 for woocommerce

commit 0934663faf019c234bdcd347aff071af27f5cced
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Thu Sep 3 15:29:29 2026 +0800

    Stop the analytics tracking proxy from accepting client-supplied server props (#68313)

    * fix(analytics): stop the tracking proxy accepting client-supplied server props

    The /track endpoint is unauthenticated and was registered on every site. Client
    properties merged after the server-derived ones, so a posted _via_ip, session_id,
    blog_id or store_id won.

    Record client events through a distinct entry point that strips server-owned
    property names, re-assert them after the props filter, and register the route
    only while proxy tracking is enabled.

    Closes WOOA7S-1803 findings 1-4.

    * fix(analytics): use a valid changelogger type on the store search entry

    `Type: fix` is not in changelogger's vocabulary, so `changelog validate` fails
    for every PR touching this package. Introduced in #68246.

    * docs(analytics): say what the reserved-props test teardown actually resets

    The docblock credited this teardown with stopping a seeded woocommerce_store_id
    from leaking. WorDBless clears options after every test; what this method covers
    is the static state WorDBless never sees.

    * refactor(analytics): drop the stale-MU-plugin strip guard

    record_event() strips reserved properties on any POST to /track, so an
    MU-plugin speed module written before record_client_event() existed is
    covered without updating the file on disk.

    That copy cannot exist: the speed module is off by default and has never
    been enabled on a public site, and any module installed from here on is
    written by a template that calls record_client_event() directly.

    Removing the guard makes record_event() purely additive: a new optional
    third parameter, defaulting to false, with no behaviour change for
    existing callers. It also removes a URL-shape test from a security path,
    and with it the request-scoped stripping that caught first-party events
    firing during a proxy POST.

    record_client_event() remains the boundary. The template's own version
    check stays, since it covers the opposite skew (a newer template loaded
    against an older package resolved by the autoloader).

diff --git a/packages/php/woocommerce-analytics/README.md b/packages/php/woocommerce-analytics/README.md
index a94852a72c7..880da3ef33e 100644
--- a/packages/php/woocommerce-analytics/README.md
+++ b/packages/php/woocommerce-analytics/README.md
@@ -72,6 +72,22 @@ add_filter( 'woocommerce_analytics_clickhouse_enabled', '__return_true' );
 add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
 ```

+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.
+
+**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
+`/track` decides, and makes the speed module install and uninstall itself on
+alternate runs. Turning it off also drops events from pages already held in a
+cache, whose visitors keep posting to an endpoint that no longer exists.
+
 ## Privacy & Consent Management

 ### WP Consent API Integration
diff --git a/packages/php/woocommerce-analytics/changelog/fix-dotcom-18362-search-event-name b/packages/php/woocommerce-analytics/changelog/fix-dotcom-18362-search-event-name
index 7eda0faae86..bb2e9e1a0bc 100644
--- a/packages/php/woocommerce-analytics/changelog/fix-dotcom-18362-search-event-name
+++ b/packages/php/woocommerce-analytics/changelog/fix-dotcom-18362-search-event-name
@@ -1,4 +1,4 @@
 Significance: patch
-Type: fix
+Type: fixed

 Rename the store search event so Tracks ingest stops rejecting it.
diff --git a/packages/php/woocommerce-analytics/changelog/wooa7s-1803-tracking-proxy-hardening b/packages/php/woocommerce-analytics/changelog/wooa7s-1803-tracking-proxy-hardening
new file mode 100644
index 00000000000..10880be4665
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/wooa7s-1803-tracking-proxy-hardening
@@ -0,0 +1,4 @@
+Significance: minor
+Type: security
+
+Stop the tracking proxy endpoint from accepting client-supplied values for server-derived event properties, and register it only on sites with proxy tracking enabled. Adds `WC_Analytics_Tracking::record_client_event()` and a third `$is_client_supplied` argument to the `jetpack_woocommerce_analytics_event_props` filter.
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 66bf88214a9..fc9d4586aaf 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
@@ -41,7 +41,10 @@ class WC_Analytics_Tracking_Proxy extends \WC_REST_Controller {
 				array(
 					'methods'             => \WP_REST_Server::CREATABLE,
 					'callback'            => array( $this, 'track_events' ),
-					'permission_callback' => '__return_true', // no need to check permissions
+					// Unauthenticated by design: this receives front-end events. The route
+					// is registered only while proxy tracking is enabled, and records via
+					// record_client_event(), which strips server-owned properties.
+					'permission_callback' => '__return_true',
 					'schema'              => array( $this, 'get_public_item_schema' ),
 				),
 			)
@@ -91,7 +94,7 @@ class WC_Analytics_Tracking_Proxy extends \WC_REST_Controller {
 			// Validate event name and properties.
 			$event_name = $event['event_name'] ?? null;
 			$properties = $event['properties'] ?? array();
-			if ( ! $event_name || ! is_array( $properties ) ) {
+			if ( ! $event_name || ! is_string( $event_name ) || ! is_array( $properties ) ) {
 				$results[ $index ] = array(
 					'success' => false,
 					'error'   => 'Missing event_name or invalid properties',
@@ -100,7 +103,7 @@ class WC_Analytics_Tracking_Proxy extends \WC_REST_Controller {
 				continue;
 			}

-			$result = WC_Analytics_Tracking::record_event( $event_name, $properties );
+			$result = WC_Analytics_Tracking::record_client_event( $event_name, $properties );

 			if ( is_wp_error( $result ) ) {
 				$results[ $index ] = array(
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 1d4e6dd159c..636ee4affd4 100644
--- a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
+++ b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
@@ -32,6 +32,32 @@ class WC_Analytics_Tracking {
 	 */
 	const DAILY_SALT_OPTION = 'woocommerce_analytics_daily_salt';

+	/**
+	 * Property names a client is authoritative for on the proxy path.
+	 *
+	 * The server's own values for these describe the /track request, not the page
+	 * the event happened on. `_via_ref` is excluded despite sharing a header with
+	 * `_dr`: it records what fired the pixel, and is not used for page attribution.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @var string[]
+	 */
+	const CLIENT_OVERRIDABLE_PROPERTIES = array( '_lg', '_dl', '_dr' );
+
+	/**
+	 * Identity and envelope property names a client may never set.
+	 *
+	 * Each is already protected by merge ordering in `get_properties()` or by
+	 * `Pixel_Builder::validate_and_sanitize()`. Listed anyway so that neither is
+	 * the only thing standing between a client and the visitor id.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @var string[]
+	 */
+	const RESERVED_IDENTITY_PROPERTIES = array( '_ui', '_ut', '_en', '_ts', 'browser_type' );
+
 	/**
 	 * Event queue.
 	 *
@@ -67,16 +93,29 @@ class WC_Analytics_Tracking {
 	 */
 	private static $cached_visitor_id = null;

+	/**
+	 * Memoized reserved property names for the current request.
+	 *
+	 * @var string[]|null
+	 */
+	private static $reserved_property_names = null;
+
 	/**
 	 * Record an event in Tracks and ClickHouse (If enabled).
 	 *
+	 * @since 0.18.0 Added the `$is_client_supplied` parameter.
+	 *
 	 * @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.
 	 *
-	 * @return bool|WP_Error True on emit or deliberate skip (no consent, bot UA,
-	 *                       or cookie-less context); WP_Error if pixel firing failed.
+	 * @return bool|WP_Error True on emit or deliberate skip (no consent, bot UA, or
+	 *                       cookie-less context); WP_Error for an unusable client
+	 *                       event name, or if the pixel could not be built or fired.
 	 */
-	public static function record_event( $event_name, $event_properties = array() ) {
+	public static function record_event( $event_name, $event_properties = array(), $is_client_supplied = false ) {
 		// Check consent before recording any event.
 		if ( ! Consent_Manager::has_analytics_consent() ) {
 			return true; // Skip recording.
@@ -92,8 +131,18 @@ class WC_Analytics_Tracking {
 			return true;
 		}

+		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.
+			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 ) );
+			}
+
+			$event_properties = self::strip_reserved_properties( $event_properties );
+		}
+
 		$prefixed_event_name = self::PREFIX . $event_name;
-		$properties          = self::get_properties( $prefixed_event_name, $event_properties );
+		$properties          = self::get_properties( $prefixed_event_name, $event_properties, $is_client_supplied );

 		// Record Tracks event.
 		$tracks_error  = null;
@@ -123,6 +172,24 @@ class WC_Analytics_Tracking {
 		return true;
 	}

+	/**
+	 * Record an event whose properties came from an untrusted client.
+	 *
+	 * The entry point for the tracking proxy: the REST controller and the MU-plugin
+	 * speed module both come through here. A distinct method rather than a sanitizer
+	 * callers must remember to invoke, so a wrong choice is visible at the call site.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param string $event_name The name of the event.
+	 * @param array  $event_properties Client-supplied properties.
+	 *
+	 * @return bool|WP_Error True on emit or deliberate skip; WP_Error if pixel firing failed.
+	 */
+	public static function record_client_event( $event_name, $event_properties = array() ) {
+		return self::record_event( $event_name, $event_properties, true );
+	}
+
 	/**
 	 * Queue an event in the event queue which will be processed on the page load in client-side analytics.
 	 *
@@ -313,23 +380,47 @@ class WC_Analytics_Tracking {
 	/**
 	 * Get all properties for the event including filtered and identity properties.
 	 *
+	 * @since 0.18.0 Added the `$is_client_supplied` parameter.
+	 *
 	 * @param string $event_name Event name.
 	 * @param array  $event_properties Event specific properties.
+	 * @param bool   $is_client_supplied Whether $event_properties came from an untrusted client.
 	 * @return array
 	 */
-	public static function get_properties( $event_name, $event_properties ) {
+	public static function get_properties( $event_name, $event_properties, $is_client_supplied = false ) {
 		$common_properties = self::get_common_properties();

 		/**
 		 * Allow defining custom event properties in WooCommerce Analytics.
 		 *
+		 * On the proxy path (`$is_client_supplied`) a reserved name a callback returns
+		 * is discarded, because the server re-asserts its own value below. Names a
+		 * callback introduces are not reserved and are kept.
+		 *
 		 * @module woocommerce-analytics
 		 *
 		 * @since 12.5
+		 * @since 0.18.0 Added the `$is_client_supplied` parameter.
 		 *
-		 * @param array $all_props Array of event props to be filtered.
+		 * @param array  $all_props Array of event props to be filtered.
+		 * @param string $event_name Event name.
+		 * @param bool   $is_client_supplied Whether the props came from an untrusted client.
 		 */
-		$properties = apply_filters( 'jetpack_woocommerce_analytics_event_props', array_merge( $common_properties, $event_properties ), $event_name );
+		$properties = apply_filters(
+			'jetpack_woocommerce_analytics_event_props',
+			array_merge( $common_properties, $event_properties ),
+			$event_name,
+			$is_client_supplied
+		);
+
+		if ( $is_client_supplied ) {
+			// A callback that defers to an existing value hands a reserved property
+			// back to the client, which supplied it. Re-assert the server's own.
+			$properties = array_merge(
+				$properties,
+				array_intersect_key( $common_properties, array_flip( self::get_reserved_property_names() ) )
+			);
+		}

 		$required_properties = $event_name
 			? array(
@@ -367,6 +458,75 @@ class WC_Analytics_Tracking {
 		return $all_properties;
 	}

+	/**
+	 * Get the property names a client may not set.
+	 *
+	 * Derived from `get_common_properties()` rather than restated as a literal, so a
+	 * newly added common property is protected with no edit here. The pinned list in
+	 * `WC_Analytics_Tracking_Reserved_Props_Test` still fails on the addition, on
+	 * purpose: protection is automatic, granting an exemption is not. Memoized because
+	 * a batch would otherwise recompute the common properties once per event.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @return string[] Reserved property names.
+	 */
+	public static function get_reserved_property_names() {
+		if ( null !== self::$reserved_property_names ) {
+			return self::$reserved_property_names;
+		}
+
+		$server_owned = array_diff(
+			array_keys( self::get_common_properties() ),
+			self::CLIENT_OVERRIDABLE_PROPERTIES
+		);
+
+		self::$reserved_property_names = array_values(
+			array_unique( array_merge( $server_owned, self::RESERVED_IDENTITY_PROPERTIES ) )
+		);
+
+		return self::$reserved_property_names;
+	}
+
+	/**
+	 * Remove server-owned properties from a client-supplied property array.
+	 *
+	 * Stripping is silent and the event still records: rejecting it would turn the
+	 * endpoint into an oracle for probing the reserved list.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param array $event_properties Client-supplied properties. A non-array is
+	 *                                tolerated, since the REST body is attacker-shaped.
+	 * @return array Properties with reserved names removed; empty array for empty or
+	 *               non-array input.
+	 */
+	public static function strip_reserved_properties( $event_properties ) {
+		if ( ! is_array( $event_properties ) || empty( $event_properties ) ) {
+			return array();
+		}
+
+		return array_diff_key(
+			$event_properties,
+			array_flip( self::get_reserved_property_names() )
+		);
+	}
+
+	/**
+	 * Whether a client-supplied event or property name is usable.
+	 *
+	 * Without the type check an array name reaches `PREFIX . $event_name` and writes
+	 * a PHP warning to the log, unauthenticated.
+	 *
+	 * @since 0.18.0
+	 *
+	 * @param mixed $name Client-supplied name.
+	 * @return bool True when the name is a non-empty string.
+	 */
+	private static function is_valid_client_name( $name ) {
+		return is_string( $name ) && '' !== $name;
+	}
+
 	/**
 	 * Get the current user id.
 	 *
diff --git a/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php b/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
index 8af1c6e7c8b..481653f3709 100644
--- a/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
+++ b/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
@@ -266,15 +266,25 @@ trait Woo_Analytics_Trait {
 		/**
 		 * Allow defining custom event properties in WooCommerce Analytics.
 		 *
+		 * See `WC_Analytics_Tracking::get_properties()` for the full contract
+		 * around `$is_client_supplied`.
+		 *
 		 * @module woocommerce-analytics
 		 *
 		 * @since 12.5
+		 * @since 0.18.0 Added the `$is_client_supplied` parameter. This call site
+		 *               also began passing `$event_name`, which the hook already had.
 		 *
-		 * @param array $properties Array of event props to be filtered.
+		 * @param array  $properties Array of event props to be filtered.
+		 * @param string $event_name Event name. Empty string here: this call builds
+		 *                           common properties, not properties for a specific event.
+		 * @param bool   $is_client_supplied Whether the props came from an untrusted client.
 		 */
 		$properties = apply_filters(
 			'jetpack_woocommerce_analytics_event_props',
-			$common_properties
+			$common_properties,
+			'',
+			false
 		);

 		return $properties;
@@ -298,7 +308,9 @@ trait Woo_Analytics_Trait {
 		/** This filter is documented in src/class-woo-analytics-trait.php */
 		return apply_filters(
 			'jetpack_woocommerce_analytics_event_props',
-			$common_properties
+			$common_properties,
+			'',
+			false
 		);
 	}

diff --git a/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php b/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php
index 677eeb99390..889bc595f32 100644
--- a/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php
+++ b/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php
@@ -22,7 +22,7 @@ class Woocommerce_Analytics {
 	/**
 	 * Package version.
 	 */
-	const PACKAGE_VERSION = '0.16.3';
+	const PACKAGE_VERSION = '0.18.0';

 	/**
 	 * Proxy speed module version option.
@@ -174,8 +174,16 @@ class Woocommerce_Analytics {

 	/**
 	 * Register REST API routes.
+	 *
+	 * The tracking proxy endpoint is unauthenticated by design — it exists to
+	 * receive front-end events — so it is registered only while proxy tracking is
+	 * enabled, rather than on every site running the package.
 	 */
 	public static function register_rest_routes() {
+		if ( ! \Automattic\Woocommerce_Analytics\Features::is_proxy_tracking_enabled() ) {
+			return;
+		}
+
 		$controller = new WC_Analytics_Tracking_Proxy();
 		$controller->register_routes();
 	}
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 684460e5155..6d248384ca9 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
@@ -61,6 +61,9 @@ class WooCommerceAnalyticsProxySpeed {
 	/**
 	 * Check if current request is a proxy request.
 	 *
+	 * Self-contained on purpose: init() calls this before load_autoloader(), so no
+	 * package class exists yet to delegate to.
+	 *
 	 * @return bool
 	 */
 	private function is_proxy_request() {
@@ -118,6 +121,13 @@ class WooCommerceAnalyticsProxySpeed {
 			return false;
 		}

+		// The autoloader resolves the highest version across active plugins, which can
+		// be older than the one that wrote this file. Fall back rather than fatal.
+		if ( ! method_exists( '\Automattic\Woocommerce_Analytics\WC_Analytics_Tracking', 'record_client_event' ) ) {
+			error_log( 'WooCommerce Analytics Proxy Speed Module: the loaded WC_Analytics_Tracking predates record_client_event().' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+			return false;
+		}
+
 		return true;
 	}

@@ -205,7 +215,7 @@ class WooCommerceAnalyticsProxySpeed {
 			$event_name = $event['event_name'] ?? null;
 			$properties = $event['properties'] ?? array();

-			if ( ! $event_name || ! is_array( $properties ) ) {
+			if ( ! $event_name || ! is_string( $event_name ) || ! is_array( $properties ) ) {
 				$results[ $index ] = array(
 					'success' => false,
 					'error'   => 'Missing event_name or invalid properties',
@@ -214,7 +224,7 @@ class WooCommerceAnalyticsProxySpeed {
 				continue;
 			}

-			$result = \Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::record_event( $event_name, $properties );
+			$result = \Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::record_client_event( $event_name, $properties );

 			if ( is_wp_error( $result ) ) {
 				$results[ $index ] = array(
diff --git a/packages/php/woocommerce-analytics/tests/php/Universal_Test.php b/packages/php/woocommerce-analytics/tests/php/Universal_Test.php
index 85e856940d9..b71979f887b 100644
--- a/packages/php/woocommerce-analytics/tests/php/Universal_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/Universal_Test.php
@@ -147,6 +147,42 @@ class Universal_Test extends BaseTestCase {
 		$this->assertSame( array(), $this->get_pixel_batch_queue(), 'No pixel should be queued when the cart item is missing.' );
 	}

+	/**
+	 * Universal consumes Woo_Analytics_Trait, whose get_common_properties()
+	 * and get_page_common_properties() both fire
+	 * jetpack_woocommerce_analytics_event_props with three arguments. A
+	 * callback registered with accepted_args = 3 used to fatal with an
+	 * ArgumentCountError because both call sites passed only one argument;
+	 * this pins the fix and the exact values passed ('' for the event name,
+	 * since neither call builds properties for a specific event, and false
+	 * for is_client_supplied, since neither is on the proxy path).
+	 */
+	public function test_trait_filter_call_sites_pass_empty_event_name_and_false_client_flag(): void {
+		$seen     = array();
+		$callback = function ( $props, $event_name, $is_client_supplied ) use ( &$seen ) {
+			$seen[] = array( $event_name, $is_client_supplied );
+			return $props;
+		};
+		add_filter( 'jetpack_woocommerce_analytics_event_props', $callback, 10, 3 );
+
+		try {
+			$universal = new Universal();
+			$universal->get_common_properties();
+			$universal->get_page_common_properties();
+		} finally {
+			remove_filter( 'jetpack_woocommerce_analytics_event_props', $callback, 10 );
+		}
+
+		$this->assertSame(
+			array(
+				array( '', false ),
+				array( '', false ),
+			),
+			$seen,
+			'Both trait call sites must invoke the filter with an empty-string event name and a false client-supplied flag, without fataling.'
+		);
+	}
+
 	/**
 	 * Reset the WC_Analytics_Tracking::$pixel_batch_queue static so each
 	 * test starts from a known-empty state.
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
new file mode 100644
index 00000000000..08b96781db2
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Proxy_Test.php
@@ -0,0 +1,283 @@
+<?php
+/**
+ * Tests for the tracking proxy REST route.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use Automattic\Woocommerce_Analytics;
+use WorDBless\BaseTestCase;
+
+/**
+ * Tests that the proxy route only exists where proxy tracking is enabled, and
+ * that dispatching a request through it goes through the untrusted-client
+ * entry point rather than the trusted one.
+ */
+class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
+
+	/**
+	 * Route path as registered with the REST server.
+	 *
+	 * @var string
+	 */
+	const ROUTE = '/woocommerce-analytics/v1/track';
+
+	/**
+	 * Snapshot of $_SERVER taken in set_up(), restored in tear_down(). The
+	 * dispatch test below controls REQUEST_METHOD/REQUEST_URI to keep the
+	 * request-shape guard in WC_Analytics_Tracking::record_event() inactive,
+	 * so a passing test can only be explained by the REST controller's own
+	 * call to record_client_event().
+	 *
+	 * @var array
+	 */
+	private $server_snapshot = array();
+
+	/**
+	 * Start each test with a clean REST server and no feature filters.
+	 */
+	public function set_up(): void {
+		parent::set_up();
+		remove_all_filters( 'woocommerce_analytics_experimental_proxy_tracking_enabled' );
+		$GLOBALS['wp_rest_server'] = null;
+		$this->server_snapshot     = $_SERVER;
+		$this->reset_pixel_batch_queue();
+	}
+
+	/**
+	 * Leave no REST server, filters, $_SERVER/$_COOKIE mutation, or queued
+	 * pixel behind. Runs unconditionally regardless of the test outcome, so a
+	 * failed assertion mid-test cannot leak the tk_ai cookie set by the
+	 * dispatch test into the next test.
+	 */
+	public function tear_down(): void {
+		remove_all_filters( 'woocommerce_analytics_experimental_proxy_tracking_enabled' );
+		$GLOBALS['wp_rest_server'] = null;
+		$_SERVER                   = $this->server_snapshot;
+		unset( $_COOKIE['tk_ai'] );
+		$this->reset_pixel_batch_queue();
+		parent::tear_down();
+	}
+
+	/**
+	 * Read the queued pixel URLs via reflection, as
+	 * WC_Analytics_Tracking_Reserved_Props_Test does.
+	 *
+	 * @return array
+	 */
+	private function get_pixel_batch_queue(): array {
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+		$property   = $reflection->getProperty( 'pixel_batch_queue' );
+		$property->setAccessible( true );
+		return $property->getValue();
+	}
+
+	/**
+	 * Clear the queued pixel URLs and every per-request memo, so one test's
+	 * cookie, IP or event cannot leak into the next. `cached_ip` in particular
+	 * survives the whole PHP process, so a test that never set REMOTE_ADDR would
+	 * otherwise pin '' for every test after it.
+	 */
+	private function reset_pixel_batch_queue(): void {
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+
+		$property = $reflection->getProperty( 'pixel_batch_queue' );
+		$property->setAccessible( true );
+		$property->setValue( null, array() );
+
+		$ip = $reflection->getProperty( 'cached_ip' );
+		$ip->setAccessible( true );
+		$ip->setValue( null, null );
+
+		$reserved = $reflection->getProperty( 'reserved_property_names' );
+		$reserved->setAccessible( true );
+		$reserved->setValue( null, null );
+
+		$visitor = $reflection->getProperty( 'cached_visitor_id' );
+		$visitor->setAccessible( true );
+		$visitor->setValue( null, null );
+	}
+
+	/**
+	 * Parse the query string of the single queued pixel into an array.
+	 *
+	 * @return array
+	 */
+	private function get_queued_pixel_props(): array {
+		$queue = $this->get_pixel_batch_queue();
+		$this->assertCount( 1, $queue, 'Expected exactly one queued pixel.' );
+
+		$query = wp_parse_url( $queue[0], PHP_URL_QUERY );
+		$props = array();
+		parse_str( (string) $query, $props );
+
+		return $props;
+	}
+
+	/**
+	 * The endpoint is unauthenticated by design, so it must not exist on the
+	 * sites that never opted into proxy tracking — which is every site, by
+	 * default.
+	 */
+	public function test_route_is_not_registered_when_proxy_tracking_is_disabled(): void {
+		Woocommerce_Analytics::register_rest_routes();
+
+		$this->assertArrayNotHasKey( self::ROUTE, rest_get_server()->get_routes() );
+	}
+
+	/**
+	 * The endpoint still exists where the feature is on, otherwise the client
+	 * has nowhere to post.
+	 */
+	public function test_route_is_registered_when_proxy_tracking_is_enabled(): void {
+		add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+
+		Woocommerce_Analytics::register_rest_routes();
+
+		$this->assertArrayHasKey( self::ROUTE, rest_get_server()->get_routes() );
+	}
+
+	/**
+	 * The security boundary is WC_Analytics_Tracking::record_client_event(),
+	 * not the request-shape guard in record_event() — that guard is documented
+	 * as a removable net for stale MU-plugin copies. This test proves the
+	 * controller itself goes through record_client_event() by dispatching a
+	 * real WP_REST_Request through the REST server with $_SERVER deliberately
+	 * shaped so the guard does NOT match (REQUEST_URI is the genuine
+	 * `?rest_route=` form, whose parsed path is `/` — see
+	 * non_proxy_request_provider() in WC_Analytics_Tracking_Reserved_Props_Test).
+	 * On a plain-permalink site this is exactly what `rest_url()` produces, so
+	 * it is also the realistic shape, not a contrived one.
+	 *
+	 * If WC_Analytics_Tracking_Proxy::track_events() is ever changed to call
+	 * record_event() instead of record_client_event(), this is the only test
+	 * that fails: every other test in the suite drives record_client_event()
+	 * or record_event() directly rather than through a real REST dispatch, so
+	 * none of them would catch a silent revert on a plain-permalink site.
+	 */
+	public function test_track_events_strips_reserved_properties_through_the_rest_route(): void {
+		$_COOKIE['tk_ai']          = 'test-visitor-id-1234567890ab';
+		$_SERVER['REQUEST_METHOD'] = 'POST';
+		$_SERVER['REQUEST_URI']    = '/?rest_route=/woocommerce-analytics/v1/track';
+
+		// Seeded so the assertions below prove the server's value replaced the
+		// forged one. Unseeded, store_id is absent from the pixel entirely and
+		// _via_ip is '', so an assertNotSame() would pass on absence alone.
+		$_SERVER['REMOTE_ADDR'] = '203.0.113.7';
+		update_option( 'woocommerce_store_id', 'real-store-id' );
+
+		add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+		Woocommerce_Analytics::register_rest_routes();
+
+		$request = new \WP_REST_Request( 'POST', self::ROUTE );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'event_name' => 'add_to_cart',
+					'properties' => array(
+						'store_id' => 'someone-elses-store',
+						'_via_ip'  => '8.8.8.8',
+						'pi'       => 42,
+					),
+				)
+			)
+		);
+
+		$response = rest_do_request( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$props = $this->get_queued_pixel_props();
+
+		$this->assertSame( 'real-store-id', $props['store_id'] ?? null, 'store_id must be the server value, not merely absent.' );
+		$this->assertSame( '203.0.113.7', $props['_via_ip'] ?? null, '_via_ip must be the server value, not merely absent.' );
+		$this->assertSame( '42', $props['pi'] ?? null, 'Event-specific properties must still survive.' );
+	}
+
+	/**
+	 * The route being absent from get_routes() is a white-box fact; what actually
+	 * has to hold is that a POST arriving anyway records nothing.
+	 */
+	public function test_post_records_nothing_on_a_site_that_never_enabled_proxy_tracking(): void {
+		$_COOKIE['tk_ai']          = 'test-visitor-id-1234567890ab';
+		$_SERVER['REQUEST_METHOD'] = 'POST';
+		$_SERVER['REQUEST_URI']    = '/?rest_route=/woocommerce-analytics/v1/track';
+
+		// No filter: proxy tracking is off: proxy tracking has never been on here.
+		Woocommerce_Analytics::register_rest_routes();
+
+		$response = rest_do_request( $this->build_track_request() );
+
+		$this->assertSame( 404, $response->get_status(), 'A site that never used proxy tracking must not expose the endpoint.' );
+		$this->assertSame( array(), $this->get_pixel_batch_queue(), 'No pixel may be queued when the route is gated off.' );
+	}
+
+	/**
+	 * A single valid event, for the tests that only care about the response.
+	 *
+	 * @return \WP_REST_Request
+	 */
+	private function build_track_request(): \WP_REST_Request {
+		$request = new \WP_REST_Request( 'POST', self::ROUTE );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'event_name' => 'add_to_cart',
+					'properties' => array( 'pi' => 42 ),
+				)
+			)
+		);
+
+		return $request;
+	}
+
+	/**
+	 * The batch loop is the controller's main path, and the memoized reserved
+	 * list is justified by batches specifically — so a later event in the same
+	 * request must still be stripped. Also covers the 207 partial-failure branch,
+	 * which nothing else exercises.
+	 */
+	public function test_batch_strips_every_event_and_reports_per_event_results(): void {
+		$_COOKIE['tk_ai']          = 'test-visitor-id-1234567890ab';
+		$_SERVER['REQUEST_METHOD'] = 'POST';
+		$_SERVER['REQUEST_URI']    = '/?rest_route=/woocommerce-analytics/v1/track';
+		update_option( 'woocommerce_store_id', 'real-store-id' );
+
+		add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+		Woocommerce_Analytics::register_rest_routes();
+
+		$request = new \WP_REST_Request( 'POST', self::ROUTE );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					// No event_name: must fail on its own without aborting the batch.
+					array( 'properties' => array( 'pi' => 1 ) ),
+					array(
+						'event_name' => 'add_to_cart',
+						'properties' => array(
+							'store_id' => 'someone-elses-store',
+							'pi'       => 2,
+						),
+					),
+				)
+			)
+		);
+
+		$response = rest_do_request( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 207, $response->get_status() );
+		$this->assertFalse( $data['results'][0]['success'] );
+		$this->assertTrue( $data['results'][1]['success'] );
+
+		$props = $this->get_queued_pixel_props();
+		$this->assertSame( 'real-store-id', $props['store_id'] ?? null, 'The memoized reserved list must still strip on a later event in the batch.' );
+		$this->assertSame( '2', $props['pi'] ?? null );
+	}
+
+}
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
new file mode 100644
index 00000000000..6ef7c360025
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
@@ -0,0 +1,513 @@
+<?php
+/**
+ * Tests for the reserved-property set that keeps client-supplied event
+ * properties from overriding the ones the server derives.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use WorDBless\BaseTestCase;
+
+/**
+ * Tests for WC_Analytics_Tracking reserved property handling.
+ */
+class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
+
+	/**
+	 * Snapshot of $_SERVER taken in set_up(), restored in tear_down().
+	 *
+	 * Restoring the full array — rather than unset()-ing the specific keys a
+	 * test touched — puts back whatever the WordPress bootstrap actually
+	 * populated, and does so from tear_down() so a failed assertion mid-test
+	 * cannot skip cleanup and leak state into the next test.
+	 *
+	 * @var array
+	 */
+	private $server_snapshot = array();
+
+	/**
+	 * Clear the memoized reserved-name list before each test.
+	 */
+	public function set_up(): void {
+		parent::set_up();
+		$this->server_snapshot = $_SERVER;
+		$this->reset_reserved_property_names();
+		$this->reset_pixel_batch_queue();
+		$this->reset_cached_ip();
+		delete_transient( 'wc_analytics_blog_details' );
+	}
+
+	/**
+	 * Reset the process-global state WorDBless does not.
+	 *
+	 * Options and transients are cleared by `BaseTestCase`, so the seeded
+	 * `woocommerce_store_id` is already handled. The memoized reserved-name list,
+	 * the pixel queue and the cached IP are static properties WorDBless never
+	 * sees, and they would otherwise carry one test's environment into the next.
+	 * Runs unconditionally, so a failed assertion cannot skip the reset.
+	 */
+	public function tear_down(): void {
+		$_SERVER = $this->server_snapshot;
+		unset( $_COOKIE['tk_ai'] );
+		$this->reset_reserved_property_names();
+		$this->reset_pixel_batch_queue();
+		$this->reset_cached_ip();
+		delete_transient( 'wc_analytics_blog_details' );
+		parent::tear_down();
+	}
+
+	/**
+	 * The memo persists for the life of the PHP process, so tests must clear it
+	 * or the first test's environment leaks into every later one.
+	 */
+	private function reset_reserved_property_names(): void {
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+		$property   = $reflection->getProperty( 'reserved_property_names' );
+		$property->setAccessible( true );
+		$property->setValue( null, null );
+	}
+
+	/**
+	 * Pins the effective reserved set. This test exists to fail: adding a
+	 * property to get_session_properties(), get_page_common_properties() or
+	 * get_server_details() must force a deliberate decision about whether a
+	 * client may set it, rather than silently inheriting protection or silently
+	 * missing out on it.
+	 */
+	public function test_reserved_property_names_match_the_documented_set(): void {
+		$expected = array(
+			// get_session_properties().
+			'session_id',
+			'landing_page',
+			'is_engaged',
+			// get_page_common_properties().
+			'ui',
+			'blog_id',
+			'store_id',
+			'url',
+			'woo_version',
+			'wp_version',
+			'store_admin',
+			'device',
+			'store_currency',
+			'timezone',
+			'is_guest',
+			// get_server_details(), minus CLIENT_OVERRIDABLE_PROPERTIES.
+			'_via_ua',
+			'_via_ip',
+			'_via_ref',
+			// Identity and envelope.
+			'_ui',
+			'_ut',
+			'_en',
+			'_ts',
+			'browser_type',
+		);
+
+		$actual = WC_Analytics_Tracking::get_reserved_property_names();
+
+		sort( $expected );
+		sort( $actual );
+
+		$this->assertSame( $expected, $actual );
+	}
+
+	/**
+	 * The three properties the client is authoritative for must not be
+	 * reserved: on the proxy path the server's values describe the /track
+	 * request, not the page the event happened on.
+	 */
+	public function test_client_overridable_properties_are_not_reserved(): void {
+		$reserved = WC_Analytics_Tracking::get_reserved_property_names();
+
+		$this->assertNotContains( '_lg', $reserved );
+		$this->assertNotContains( '_dl', $reserved );
+		$this->assertNotContains( '_dr', $reserved );
+	}
+
+	/**
+	 * The strip removes reserved names and leaves everything else — including
+	 * arbitrary event-specific properties — untouched.
+	 */
+	public function test_strip_removes_reserved_names_only(): void {
+		$stripped = WC_Analytics_Tracking::strip_reserved_properties(
+			array(
+				'_via_ip'     => '8.8.8.8',
+				'store_id'    => 'someone-elses-store',
+				'blog_id'     => 12345,
+				'session_id'  => 'forged-session',
+				'store_admin' => 1,
+				'is_guest'    => 0,
+				'_ui'         => 'forged-visitor',
+				'_lg'         => 'en-GB',
+				'_dl'         => 'https://example.com/product/thing',
+				'_dr'         => 'https://example.com/',
+				'pi'          => 42,
+				'pq'          => 2,
+			)
+		);
+
+		$this->assertSame(
+			array(
+				'_lg' => 'en-GB',
+				'_dl' => 'https://example.com/product/thing',
+				'_dr' => 'https://example.com/',
+				'pi'  => 42,
+				'pq'  => 2,
+			),
+			$stripped
+		);
+	}
+
+	/**
+	 * Defensive: the REST body is attacker-shaped, so a non-array or empty
+	 * value must not fatal.
+	 */
+	public function test_strip_handles_empty_and_non_array_input(): void {
+		$this->assertSame( array(), WC_Analytics_Tracking::strip_reserved_properties( array() ) );
+		$this->assertSame( array(), WC_Analytics_Tracking::strip_reserved_properties( 'not-an-array' ) );
+		$this->assertSame( array(), WC_Analytics_Tracking::strip_reserved_properties( null ) );
+	}
+
+	/**
+	 * Read the queued pixel URLs. record_event() queues rather than sends when
+	 * the Requests library supports request_multiple(), which it does here.
+	 *
+	 * @return array
+	 */
+	private function get_pixel_batch_queue(): array {
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+		$property   = $reflection->getProperty( 'pixel_batch_queue' );
+		$property->setAccessible( true );
+		return $property->getValue();
+	}
+
+	/**
+	 * Clear the queued pixel URLs and the cached visitor id, so one test's
+	 * cookie cannot leak into the next.
+	 */
+	private function reset_pixel_batch_queue(): void {
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+
+		$property = $reflection->getProperty( 'pixel_batch_queue' );
+		$property->setAccessible( true );
+		$property->setValue( null, array() );
+
+		$visitor = $reflection->getProperty( 'cached_visitor_id' );
+		$visitor->setAccessible( true );
+		$visitor->setValue( null, null );
+	}
+
+	/**
+	 * Clear the cached IP address, so a REMOTE_ADDR seeded for one test cannot
+	 * leak into the next: get_user_ip_address() memoizes its result for the
+	 * life of the PHP process.
+	 */
+	private function reset_cached_ip(): void {
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+		$property   = $reflection->getProperty( 'cached_ip' );
+		$property->setAccessible( true );
+		$property->setValue( null, null );
+	}
+
+	/**
+	 * Parse the query string of the single queued pixel into an array.
+	 *
+	 * @return array
+	 */
+	private function get_queued_pixel_props(): array {
+		$queue = $this->get_pixel_batch_queue();
+		$this->assertCount( 1, $queue, 'Expected exactly one queued pixel.' );
+
+		$query = wp_parse_url( $queue[0], PHP_URL_QUERY );
+		$props = array();
+		parse_str( (string) $query, $props );
+
+		return $props;
+	}
+
+	/**
+	 * A client that posts server-owned properties must not see them reach the
+	 * pixel. This is the core of WOOA7S-1803.
+	 *
+	 * Both `store_id` and `_via_ip` are seeded with a real server-derived value
+	 * before the call, so the assertions prove the server's value replaced the
+	 * client's forged one, not merely that the forged one is absent. Under
+	 * WorDBless, `get_option( 'woocommerce_store_id', null )` is null and
+	 * `http_build_query()` drops null values, so an assertNotSame() against an
+	 * unseeded environment would pass on absence alone and miss a stripped-but-
+	 * not-substituted bug.
+	 */
+	public function test_record_client_event_drops_client_supplied_server_properties(): void {
+		$_COOKIE['tk_ai']       = 'test-visitor-id-1234567890ab';
+		$_SERVER['REMOTE_ADDR'] = '203.0.113.7';
+		update_option( 'woocommerce_store_id', 'real-store-id' );
+		$this->reset_pixel_batch_queue();
+
+		WC_Analytics_Tracking::record_client_event(
+			'add_to_cart',
+			array(
+				'_via_ip'  => '8.8.8.8',
+				'store_id' => 'someone-elses-store',
+				'_ui'      => 'forged-visitor',
+				'pi'       => 42,
+			)
+		);
+
+		$props = $this->get_queued_pixel_props();
+
+		$this->assertSame( '203.0.113.7', $props['_via_ip'] ?? null, '_via_ip must come from the server (REMOTE_ADDR), not the client.' );
+		$this->assertSame( 'real-store-id', $props['store_id'] ?? null, 'store_id must come from the server (woocommerce_store_id option), not the client.' );
+		$this->assertSame( 'test-visitor-id-1234567890ab', $props['_ui'] ?? null, '_ui must come from the tk_ai cookie.' );
+		$this->assertSame( '42', $props['pi'] ?? null, 'Event-specific properties must survive.' );
+
+		$this->reset_pixel_batch_queue();
+		unset( $_COOKIE['tk_ai'] );
+	}
+
+	/**
+	 * The three client-authoritative properties must survive to the pixel,
+	 * otherwise proxy-mode page attribution breaks: the server's values would
+	 * describe the /track request instead of the page.
+	 */
+	public function test_record_client_event_keeps_client_authoritative_properties(): void {
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+		$this->reset_pixel_batch_queue();
+
+		WC_Analytics_Tracking::record_client_event(
+			'add_to_cart',
+			array(
+				'_lg' => 'en-GB',
+				'_dl' => 'https://example.com/product/thing',
+				'_dr' => 'https://example.com/',
+			)
+		);
+
+		$props = $this->get_queued_pixel_props();
+
+		$this->assertSame( 'en-GB', $props['_lg'] ?? null );
+		$this->assertSame( 'https://example.com/product/thing', $props['_dl'] ?? null );
+		$this->assertSame( 'https://example.com/', $props['_dr'] ?? null );
+
+		$this->reset_pixel_batch_queue();
+		unset( $_COOKIE['tk_ai'] );
+	}
+
+	/**
+	 * The trusted path is unchanged: a server-side caller can still set a
+	 * property that collides with a common one. Universal and My_Account rely
+	 * on record_event() keeping these semantics.
+	 */
+	public function test_record_event_leaves_trusted_caller_properties_alone(): void {
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+		$this->reset_pixel_batch_queue();
+
+		WC_Analytics_Tracking::record_event(
+			'add_to_cart',
+			array( 'store_id' => 'set-by-trusted-caller' )
+		);
+
+		$props = $this->get_queued_pixel_props();
+
+		$this->assertSame( 'set-by-trusted-caller', $props['store_id'] ?? null );
+
+		$this->reset_pixel_batch_queue();
+		unset( $_COOKIE['tk_ai'] );
+	}
+
+	/**
+	 * A callback that assigns unconditionally beats a client value of the same
+	 * name. This is the pattern the filter docblock tells extensions to use.
+	 */
+	public function test_filter_callback_assigning_unconditionally_beats_the_client(): void {
+		$callback = function ( $props ) {
+			$props['partner_tier'] = 'gold';
+			return $props;
+		};
+		add_filter( 'jetpack_woocommerce_analytics_event_props', $callback );
+
+		$props = WC_Analytics_Tracking::get_properties(
+			'woocommerceanalytics_add_to_cart',
+			array( 'partner_tier' => 'forged' ),
+			true
+		);
+
+		remove_filter( 'jetpack_woocommerce_analytics_event_props', $callback );
+
+		$this->assertSame( 'gold', $props['partner_tier'] );
+	}
+
+	/**
+	 * The known limitation, asserted so it is a recorded decision rather than an
+	 * assumption: a callback that defers to an existing value loses to the
+	 * client, because the reserved set cannot cover names the filter invents.
+	 * If this ever starts passing, the limitation has been closed and the spec
+	 * needs updating.
+	 */
+	public function test_filter_callback_deferring_to_an_existing_value_loses_to_the_client(): void {
+		$callback = function ( $props ) {
+			$props['partner_tier'] = isset( $props['partner_tier'] ) ? $props['partner_tier'] : 'gold';
+			return $props;
+		};
+		add_filter( 'jetpack_woocommerce_analytics_event_props', $callback );
+
+		$props = WC_Analytics_Tracking::get_properties(
+			'woocommerceanalytics_add_to_cart',
+			array( 'partner_tier' => 'forged' ),
+			true
+		);
+
+		remove_filter( 'jetpack_woocommerce_analytics_event_props', $callback );
+
+		$this->assertSame( 'forged', $props['partner_tier'], 'Known limitation: see the spec.' );
+	}
+
+	/**
+	 * 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.
+	 */
+	public function test_filter_receives_the_client_supplied_flag(): void {
+		$seen     = array();
+		$callback = function ( $props, $event_name, $is_client_supplied ) use ( &$seen ) {
+			$seen[] = array( $event_name, $is_client_supplied );
+			return $props;
+		};
+		add_filter( 'jetpack_woocommerce_analytics_event_props', $callback, 10, 3 );
+
+		WC_Analytics_Tracking::get_properties( 'woocommerceanalytics_add_to_cart', array(), true );
+		WC_Analytics_Tracking::get_properties( 'woocommerceanalytics_product_view', array(), false );
+
+		remove_filter( 'jetpack_woocommerce_analytics_event_props', $callback, 10 );
+
+		$this->assertSame(
+			array(
+				array( 'woocommerceanalytics_add_to_cart', true ),
+				array( 'woocommerceanalytics_product_view', false ),
+			),
+			$seen
+		);
+	}
+
+	/**
+	 * 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.
+	 */
+	public function test_filter_callback_cannot_hand_a_reserved_property_back_to_the_client(): void {
+		update_option( 'woocommerce_store_id', 'real-store-id' );
+
+		$callback = function ( $props ) {
+			$props['store_id'] = isset( $props['store_id'] ) ? $props['store_id'] : 'unused';
+			return $props;
+		};
+		add_filter( 'jetpack_woocommerce_analytics_event_props', $callback );
+
+		$props = WC_Analytics_Tracking::get_properties(
+			'woocommerceanalytics_add_to_cart',
+			array( 'store_id' => 'someone-elses-store' ),
+			true
+		);
+
+		remove_filter( 'jetpack_woocommerce_analytics_event_props', $callback );
+
+		$this->assertSame( 'real-store-id', $props['store_id'] );
+	}
+
+	/**
+	 * 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.
+	 */
+	public function test_reserved_properties_are_not_re_asserted_for_trusted_callers(): void {
+		update_option( 'woocommerce_store_id', 'real-store-id' );
+
+		$props = WC_Analytics_Tracking::get_properties(
+			'woocommerceanalytics_add_to_cart',
+			array( 'store_id' => 'set-by-trusted-caller' ),
+			false
+		);
+
+		$this->assertSame( 'set-by-trusted-caller', $props['store_id'] );
+	}
+
+	/**
+	 * `_ts` is in $required_properties and in RESERVED_IDENTITY_PROPERTIES, so a
+	 * client cannot forge the event timestamp at either layer.
+	 */
+	public function test_client_cannot_forge_the_event_timestamp(): void {
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+
+		WC_Analytics_Tracking::record_client_event(
+			'add_to_cart',
+			array(
+				'_ts' => '1',
+				'pi'  => 42,
+			)
+		);
+
+		$props = $this->get_queued_pixel_props();
+
+		$this->assertMatchesRegularExpression( '/^\d{13}$/', (string) ( $props['_ts'] ?? '' ), '_ts must be the server timestamp.' );
+	}
+
+	/**
+	 * 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.
+	 *
+	 * @dataProvider unusable_client_event_name_provider
+	 *
+	 * @param mixed $event_name Name a client could post.
+	 */
+	public function test_client_events_with_an_unusable_name_are_dropped( $event_name ): void {
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+		$this->reset_pixel_batch_queue();
+
+		$result = WC_Analytics_Tracking::record_client_event( $event_name, array( 'pi' => 42 ) );
+
+		$this->assertTrue( is_wp_error( $result ), 'Reporting success for an event that produced no pixel is what hides the loss.' );
+		$this->assertSame( 'invalid_event_name', $result->get_error_code() );
+		$this->assertSame( array(), $this->get_pixel_batch_queue(), 'No pixel may be queued for an unusable event name.' );
+	}
+
+	/**
+	 * Names a client could post that cannot become an `_en` value.
+	 *
+	 * @return array<string, array{0: mixed}>
+	 */
+	public function unusable_client_event_name_provider(): array {
+		return array(
+			'array'      => array( array( 'product_view' ) ),
+			'nested map' => array( array( 'name' => 'product_view' ) ),
+			'empty'      => array( '' ),
+		);
+	}
+
+	/**
+	 * 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.
+	 */
+	public function test_mu_plugin_template_stays_in_step_with_the_package(): void {
+		$template = file_get_contents(
+			dirname( __DIR__, 2 ) . '/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php'
+		);
+
+		$this->assertStringContainsString(
+			'::record_client_event(',
+			$template,
+			'The template must record through the untrusted-client entry point.'
+		);
+		$this->assertStringNotContainsString(
+			'::record_event(',
+			$template,
+			'The template must not call the trusted entry point.'
+		);
+	}
+}
diff --git a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Test.php b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Test.php
index c6bdeb821d6..ea52fae124c 100644
--- a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Test.php
@@ -55,6 +55,10 @@ class WC_Analytics_Tracking_Test extends BaseTestCase {
 		$queue = $reflection->getProperty( 'pixel_batch_queue' );
 		$queue->setAccessible( true );
 		$queue->setValue( null, array() );
+
+		$reserved = $reflection->getProperty( 'reserved_property_names' );
+		$reserved->setAccessible( true );
+		$reserved->setValue( null, null );
 	}

 	/**
diff --git a/packages/php/woocommerce-analytics/tests/php/Woocommerce_Analytics_Test.php b/packages/php/woocommerce-analytics/tests/php/Woocommerce_Analytics_Test.php
index b7373b3e977..c1483f5f286 100644
--- a/packages/php/woocommerce-analytics/tests/php/Woocommerce_Analytics_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/Woocommerce_Analytics_Test.php
@@ -140,7 +140,6 @@ class Woocommerce_Analytics_Test extends BaseTestCase {
 	 * Test that update is skipped when version matches current package version.
 	 */
 	public function test_maybe_update_proxy_speed_module_skips_when_version_matches(): void {
-		// Enable the feature flag so the update path is checked.
 		add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );

 		// Set version to match current.
diff --git a/packages/php/woocommerce-analytics/tests/php/bootstrap.php b/packages/php/woocommerce-analytics/tests/php/bootstrap.php
index 77703409413..7e525d9d918 100644
--- a/packages/php/woocommerce-analytics/tests/php/bootstrap.php
+++ b/packages/php/woocommerce-analytics/tests/php/bootstrap.php
@@ -17,3 +17,6 @@ require_once __DIR__ . '/mocks/class-wc-tracks.php';

 // Initialize WordPress test environment using WorDBless (database-less).
 \WorDBless\Load::load();
+
+// Depends on WP_REST_Controller, so it must come after WorDBless has loaded core.
+require_once __DIR__ . '/mocks/class-wc-rest-controller.php';
diff --git a/packages/php/woocommerce-analytics/tests/php/mocks/class-wc-rest-controller.php b/packages/php/woocommerce-analytics/tests/php/mocks/class-wc-rest-controller.php
new file mode 100644
index 00000000000..d73788edd30
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/mocks/class-wc-rest-controller.php
@@ -0,0 +1,18 @@
+<?php
+/**
+ * Minimal WC_REST_Controller stub.
+ *
+ * WooCommerce is not loaded under WorDBless, so the real controller base class
+ * is unavailable and anything extending it cannot be instantiated in tests. The
+ * package only relies on the WP_REST_Controller behaviour it inherits, so an
+ * empty subclass is enough.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+if ( ! class_exists( 'WC_REST_Controller' ) ) {
+	/**
+	 * Stub for WooCommerce's REST controller base class.
+	 */
+	class WC_REST_Controller extends WP_REST_Controller {} // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
+}