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
+}