Commit 446523719cf for woocommerce

commit 446523719cf504ae2d2a3eb8894cfe3729f2045e
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Mon Jul 27 17:19:44 2026 +0800

    Stop sending request-derived props into cacheable analytics page output (#67003)

    * Stop sending request-derived props into cacheable analytics page output

    * Add changelog entry for cacheable page output fix

    * Move session properties out of cacheable page output to the client

    * Cap the search term queued into cacheable page output

    * Restore mutated globals in page output tests and drop test regex

    * Correct page-props docblocks and mark truncated page strings with an ellipsis

    The page-props docblock claimed everything it returns is store-derived, but
    `device` is User-Agent derived and `ui`/`is_guest`/`store_admin` describe the
    current user. Name both exceptions so the guardrail does not overstate.

    Lead both `get_common_properties()` docblocks with the warning — that is the
    name reached for by default, and the trait's copy carried none at all.

    `cap_page_string()` now appends an ellipsis, so a truncated value stays
    distinguishable downstream from one that genuinely ended at the limit. Its
    rationale no longer claims an unbounded search term either:
    `WP_Query::parse_query()` blanks `s` above 1600 bytes, while breadcrumb titles
    come from `post_title` and have no such clamp.

    Tests pin the cap by character and at the limit, and drive the search term at a
    length a request can actually produce.

diff --git a/packages/php/woocommerce-analytics/changelog/fix-cacheable-page-request-derived-props b/packages/php/woocommerce-analytics/changelog/fix-cacheable-page-request-derived-props
new file mode 100644
index 00000000000..93b8999f679
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-cacheable-page-request-derived-props
@@ -0,0 +1,4 @@
+Significance: patch
+Type: security
+
+Stop emitting request-derived properties into cacheable front-end page HTML.
diff --git a/packages/php/woocommerce-analytics/src/class-universal.php b/packages/php/woocommerce-analytics/src/class-universal.php
index f1a28dbff78..8c13712170c 100644
--- a/packages/php/woocommerce-analytics/src/class-universal.php
+++ b/packages/php/woocommerce-analytics/src/class-universal.php
@@ -76,7 +76,9 @@ class Universal {
 		$is_clickhouse_enabled     = Features::is_clickhouse_enabled();
 		$is_proxy_tracking_enabled = Features::is_proxy_tracking_enabled();
 		// When proxy tracking is enabled, we don't need to send the common properties to the client.
-		$common_properties = $is_proxy_tracking_enabled ? array() : $this->get_common_properties();
+		// Otherwise send only the page-safe properties: this markup is cacheable, so nothing
+		// derived from the current request may go into it.
+		$common_properties = $is_proxy_tracking_enabled ? array() : $this->get_page_common_properties();
 		?>
 		<script type="text/javascript">
 			(function() {
@@ -536,6 +538,10 @@ class Universal {

 	/**
 	 * Capture a search event.
+	 *
+	 * The term is capped because this event is assembled here but fired by the
+	 * client, so it travels through the page markup and then reaches the pixel
+	 * URL. See `cap_page_string()`.
 	 */
 	public function capture_search_query() {
 		if ( is_search() ) {
@@ -543,7 +549,7 @@ class Universal {
 			$this->enqueue_event(
 				'search',
 				array(
-					'search_query' => $wp_query->get( 's' ),
+					'search_query' => $this->cap_page_string( $wp_query->get( 's' ) ),
 					'qty'          => $wp_query->found_posts,
 				)
 			);
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 a56c1968027..1d4e6dd159c 100644
--- a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
+++ b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
@@ -237,37 +237,77 @@ class WC_Analytics_Tracking {
 	}

 	/**
-	 * Get the common properties for the event.
+	 * Request-scoped — not for page output, see `get_page_common_properties()`.
+	 *
+	 * Includes the session cookie and `get_server_details()`, so this is only safe
+	 * for events the server fires itself on an uncached request, which includes the
+	 * proxy tracking endpoint.
 	 *
 	 * @return array The common properties.
 	 */
 	public static function get_common_properties() {
-		$blog_user_id    = self::get_blog_user_id();
-		$server_details  = self::get_server_details();
-		$blog_details    = self::get_blog_details();
+		return array_merge(
+			self::get_session_properties(),
+			self::get_page_common_properties(),
+			self::get_server_details()
+		);
+	}
+
+	/**
+	 * Get the visitor's session properties from the session cookie.
+	 *
+	 * Request-derived, so these are for the server-fired path only. The cookie is
+	 * written and read by the client's own SessionManager, which supplies these
+	 * properties directly on events it sends.
+	 *
+	 * @since 0.16.7
+	 *
+	 * @return array The session properties.
+	 */
+	private static function get_session_properties() {
 		$session_details = self::get_session_details();

-		$common_properties = array_merge(
-			array(
-				'session_id'     => $session_details['session_id'] ?? null,
-				'landing_page'   => $session_details['landing_page'] ?? null,
-				'is_engaged'     => $session_details['is_engaged'] ?? null,
-				'ui'             => $blog_user_id,
-				'blog_id'        => $blog_details['blog_id'] ?? null,
-				'store_id'       => $blog_details['store_id'] ?? null,
-				'url'            => $blog_details['url'] ?? null,
-				'woo_version'    => $blog_details['wc_version'] ?? null,
-				'wp_version'     => get_bloginfo( 'version' ),
-				'store_admin'    => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
-				'device'         => self::get_device_type(),
-				'store_currency' => $blog_details['store_currency'] ?? null,
-				'timezone'       => wp_timezone_string(),
-				'is_guest'       => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
-			),
-			$server_details
+		return array(
+			'session_id'   => $session_details['session_id'] ?? null,
+			'landing_page' => $session_details['landing_page'] ?? null,
+			'is_engaged'   => $session_details['is_engaged'] ?? null,
 		);
+	}

-		return is_array( $common_properties ) ? $common_properties : array();
+	/**
+	 * Get the common properties that are safe to embed in cacheable page HTML.
+	 *
+	 * Request headers and cookies are not part of the CDN cache key, so a property
+	 * derived from one is attributed to every later visitor of the cached page.
+	 * Anything request-derived belongs in `get_session_properties()` or
+	 * `get_server_details()`, which only reach the server-fired path.
+	 *
+	 * Two exceptions, neither of them licence to add a third: `device` is
+	 * User-Agent derived and a known gap, tracked for a client-side follow-up;
+	 * `ui`, `is_guest` and `store_admin` are safe only because caches bypass
+	 * logged-in requests.
+	 *
+	 * @since 0.16.7
+	 *
+	 * @return array The common properties.
+	 */
+	public static function get_page_common_properties() {
+		$blog_user_id = self::get_blog_user_id();
+		$blog_details = self::get_blog_details();
+
+		return array(
+			'ui'             => $blog_user_id,
+			'blog_id'        => $blog_details['blog_id'] ?? null,
+			'store_id'       => $blog_details['store_id'] ?? null,
+			'url'            => $blog_details['url'] ?? null,
+			'woo_version'    => $blog_details['wc_version'] ?? null,
+			'wp_version'     => get_bloginfo( 'version' ),
+			'store_admin'    => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
+			'device'         => self::get_device_type(),
+			'store_currency' => $blog_details['store_currency'] ?? null,
+			'timezone'       => wp_timezone_string(),
+			'is_guest'       => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
+		);
 	}

 	/**
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 dec98fd80dc..8d7173b6ee0 100644
--- a/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
+++ b/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
@@ -254,7 +254,10 @@ trait Woo_Analytics_Trait {
 	}

 	/**
-	 * Default event properties which should be included with all events.
+	 * Request-scoped — not for page output, see `get_page_common_properties()`.
+	 *
+	 * Default event properties for events the server fires itself on an uncached
+	 * request. See `WC_Analytics_Tracking::get_common_properties()`.
 	 *
 	 * @return array Array of standard event props.
 	 */
@@ -277,6 +280,28 @@ trait Woo_Analytics_Trait {
 		return $properties;
 	}

+	/**
+	 * Default event properties for the client, excluding anything derived from
+	 * the current request.
+	 *
+	 * Used for the properties embedded in front-end page HTML, which is cached
+	 * and replayed to later visitors. See
+	 * `WC_Analytics_Tracking::get_page_common_properties()`.
+	 *
+	 * @since 0.16.7
+	 *
+	 * @return array Array of standard event props.
+	 */
+	public function get_page_common_properties() {
+		$common_properties = WC_Analytics_Tracking::get_page_common_properties();
+
+		/** This filter is documented in src/class-woo-analytics-trait.php */
+		return apply_filters(
+			'jetpack_woocommerce_analytics_event_props',
+			$common_properties
+		);
+	}
+
 	/**
 	 * Enqueue an event with optional product and custom properties.
 	 *
@@ -604,9 +629,48 @@ trait Woo_Analytics_Trait {
 	 * - For regular pages, it builds the breadcrumb from the page's ancestors, ordered from top-level to current.
 	 * - For all other cases, it returns the current page's title.
 	 *
+	 * Titles are capped before being returned; see `cap_page_string()`.
+	 *
 	 * @return array The breadcrumb trail as an array of titles.
 	 */
 	private function get_breadcrumb_titles() {
+		return array_map( array( $this, 'cap_page_string' ), $this->build_breadcrumb_titles() );
+	}
+
+	/**
+	 * Limit the length of a caller-influenced string bound for the page output.
+	 *
+	 * Breadcrumb titles come from `post_title`, which core does not bound, so this
+	 * is their only limit. The search term is already blanked by
+	 * `WP_Query::parse_query()` above 1600 bytes; capping it further keeps the
+	 * search pixel URL, which carries the term twice, from being rejected outright.
+	 *
+	 * Truncated values keep an ellipsis so they stay distinguishable downstream
+	 * from a value that genuinely ended at the limit.
+	 *
+	 * @since 0.16.7
+	 *
+	 * @param string $value The value bound for the page output.
+	 * @return string The value, truncated if it exceeded the limit.
+	 */
+	private function cap_page_string( $value ) {
+		$max_length = 200;
+
+		$value = (string) $value;
+
+		if ( mb_strlen( $value ) <= $max_length ) {
+			return $value;
+		}
+
+		return mb_substr( $value, 0, $max_length - 1 ) . '…';
+	}
+
+	/**
+	 * Build the uncapped breadcrumb trail. See `get_breadcrumb_titles()`.
+	 *
+	 * @return array The breadcrumb trail as an array of titles.
+	 */
+	private function build_breadcrumb_titles() {
 		if ( is_front_page() ) {
 			return array( __( 'Home', 'woocommerce-analytics' ) );
 		}
diff --git a/packages/php/woocommerce-analytics/src/client/analytics.ts b/packages/php/woocommerce-analytics/src/client/analytics.ts
index 1a3b3090913..a95e96daac0 100644
--- a/packages/php/woocommerce-analytics/src/client/analytics.ts
+++ b/packages/php/woocommerce-analytics/src/client/analytics.ts
@@ -62,15 +62,16 @@ export class Analytics {
 			consentManager.addConsentChangeListener( this.handleConsentChange );

 			this.sessionManager.init();
-			const { sessionId, landingPage, isNewSession } = this.sessionManager;
+			const { sessionId, landingPage, isEngaged, isNewSession } = this.sessionManager;

-			// Not needed if proxy tracking is enabled.
+			// Not needed if proxy tracking is enabled: that request carries the session cookie itself.
 			if ( ! this.features.proxy ) {
-				// Add session ID and landing page to common properties.
+				// The page markup is cacheable, so the server cannot send these.
 				this.commonProps = {
 					...this.commonProps,
 					session_id: sessionId,
 					landing_page: landingPage,
+					is_engaged: isEngaged,
 				};
 			}

diff --git a/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php b/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php
new file mode 100644
index 00000000000..c3ddb34f210
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php
@@ -0,0 +1,467 @@
+<?php
+/**
+ * Tests for the analytics payload injected into front-end page HTML.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use WorDBless\BaseTestCase;
+
+/**
+ * Tests that the cacheable page output carries no request-derived properties.
+ *
+ * `inject_analytics_data()` writes into front-end HTML, which CDNs cache and
+ * replay to later visitors. Request headers such as `Referer` and
+ * `Cf-Connecting-Ip` are not part of the cache key, so any property derived
+ * from them that reaches the page body is served back to everyone who hits
+ * the cached copy — one unauthenticated request would otherwise decide what
+ * every subsequent visitor reports as their own IP, user agent and referrer.
+ */
+class Universal_Page_Output_Test extends BaseTestCase {
+
+	/**
+	 * Properties that `get_server_details()` derives from the current request.
+	 * None of them may appear in cacheable page output.
+	 *
+	 * @var string[]
+	 */
+	private const REQUEST_DERIVED_PROPERTIES = array( '_via_ip', '_via_ua', '_via_ref', '_dr', '_dl', '_lg' );
+
+	/**
+	 * Properties read from the visitor's session cookie. The client owns this
+	 * cookie and reads it itself, so these must not reach cacheable page output
+	 * either.
+	 *
+	 * @var string[]
+	 */
+	private const SESSION_PROPERTIES = array( 'session_id', 'landing_page', 'is_engaged' );
+
+	/**
+	 * Request header values used to poison the page, keyed by superglobal key.
+	 * Each is distinctive enough to grep the whole response body for.
+	 *
+	 * @var array<string, string>
+	 */
+	private const POISONED_HEADERS = array(
+		'HTTP_REFERER'          => 'https://attacker.example/poisoned-referrer',
+		'HTTP_CF_CONNECTING_IP' => '203.0.113.199',
+		'HTTP_USER_AGENT'       => 'PoisonedUserAgent/1.0',
+		'HTTP_ACCEPT_LANGUAGE'  => 'zz-ZZ',
+	);
+
+	/**
+	 * A session cookie value carrying another visitor's session.
+	 *
+	 * @var string
+	 */
+	private const POISONED_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
+
+	/**
+	 * Globals this class overwrites, captured before the first change so
+	 * `tear_down()` can put back exactly what it found. `null` records a key
+	 * that was absent.
+	 *
+	 * @var array<string, mixed>
+	 */
+	private $original_globals = array();
+
+	/**
+	 * Send every request through the poisoned headers and a session cookie, and
+	 * clear the cached IP so each test resolves them afresh.
+	 */
+	public function set_up(): void {
+		parent::set_up();
+
+		global $wp_query;
+
+		$this->original_globals = array(
+			'cookie'      => $_COOKIE['woocommerceanalytics_session'] ?? null,
+			'post'        => $GLOBALS['post'] ?? null,
+			'is_search'   => $wp_query->is_search,
+			'search_term' => $wp_query->get( 's' ),
+		);
+
+		foreach ( self::POISONED_HEADERS as $key => $value ) {
+			$this->original_globals[ 'server:' . $key ] = $_SERVER[ $key ] ?? null;
+
+			$_SERVER[ $key ] = $value;
+		}
+
+		$_COOKIE['woocommerceanalytics_session'] = rawurlencode(
+			(string) wp_json_encode(
+				array(
+					'session_id'   => self::POISONED_SESSION_ID,
+					'landing_page' => '["Someone else\'s landing page"]',
+					'is_engaged'   => true,
+				)
+			)
+		);
+
+		$this->reset_cached_ip();
+	}
+
+	/**
+	 * Put back every global this class touched, so a later test sees the state
+	 * it would have seen had this class not run.
+	 */
+	public function tear_down(): void {
+		global $wp_query;
+
+		// Superglobals cannot be passed by reference, so each is restored inline.
+		foreach ( array_keys( self::POISONED_HEADERS ) as $key ) {
+			if ( $this->original_globals[ 'server:' . $key ] === null ) {
+				unset( $_SERVER[ $key ] );
+			} else {
+				$_SERVER[ $key ] = $this->original_globals[ 'server:' . $key ];
+			}
+		}
+
+		if ( $this->original_globals['cookie'] === null ) {
+			unset( $_COOKIE['woocommerceanalytics_session'] );
+		} else {
+			$_COOKIE['woocommerceanalytics_session'] = $this->original_globals['cookie'];
+		}
+
+		if ( $this->original_globals['post'] === null ) {
+			unset( $GLOBALS['post'] );
+		} else {
+			$GLOBALS['post'] = $this->original_globals['post'];
+		}
+
+		$wp_query->is_search = $this->original_globals['is_search'];
+		$wp_query->set( 's', $this->original_globals['search_term'] );
+
+		$this->original_globals = array();
+
+		$this->reset_cached_ip();
+		$this->reset_event_queue();
+
+		parent::tear_down();
+	}
+
+	/**
+	 * Test that the visitor's session properties stay out of the page output.
+	 *
+	 * The client reads the same cookie through its own SessionManager, so these
+	 * are redundant in the markup — and the markup is cached, which would pin
+	 * one visitor's session identifier onto everyone served the cached copy.
+	 */
+	public function test_page_output_omits_session_properties(): void {
+		$output = $this->render_analytics_data();
+
+		foreach ( self::SESSION_PROPERTIES as $property ) {
+			$this->assertStringNotContainsString(
+				'"' . $property . '"',
+				$output,
+				sprintf( 'The "%s" session property must not reach cacheable page output.', $property )
+			);
+		}
+
+		$this->assertStringNotContainsString(
+			self::POISONED_SESSION_ID,
+			$output,
+			'A session identifier from the request cookie must not be reflected into cacheable page output.'
+		);
+	}
+
+	/**
+	 * Test that the server-fired path keeps the session properties. It runs on
+	 * uncached requests — including the proxy tracking endpoint — where the
+	 * cookie genuinely belongs to the visitor being recorded.
+	 */
+	public function test_server_fired_properties_retain_session_details(): void {
+		$properties = WC_Analytics_Tracking::get_common_properties();
+
+		foreach ( self::SESSION_PROPERTIES as $property ) {
+			$this->assertArrayHasKey(
+				$property,
+				$properties,
+				sprintf( 'The server-fired path still needs the "%s" property.', $property )
+			);
+		}
+
+		$this->assertSame( self::POISONED_SESSION_ID, $properties['session_id'] );
+		$this->assertTrue( $properties['is_engaged'] );
+	}
+
+	/**
+	 * Test that no request-derived property name reaches the page output.
+	 */
+	public function test_page_output_omits_request_derived_property_names(): void {
+		$output = $this->render_analytics_data();
+
+		foreach ( self::REQUEST_DERIVED_PROPERTIES as $property ) {
+			$this->assertStringNotContainsString(
+				'"' . $property . '"',
+				$output,
+				sprintf(
+					'The "%s" property is derived from the current request and must not reach cacheable page output.',
+					$property
+				)
+			);
+		}
+	}
+
+	/**
+	 * Test that no value taken from a request header reaches the page output.
+	 *
+	 * This is the poisoning check: headers outside the CDN cache key must not
+	 * influence the cached response body at all.
+	 */
+	public function test_page_output_omits_request_header_values(): void {
+		$output = $this->render_analytics_data();
+
+		foreach ( self::POISONED_HEADERS as $key => $value ) {
+			$this->assertStringNotContainsString(
+				$value,
+				$output,
+				sprintf( 'The %s request header must not be reflected into cacheable page output.', $key )
+			);
+		}
+	}
+
+	/**
+	 * Test that store-level properties survive, so the assertions above cannot
+	 * pass merely because the payload went empty.
+	 */
+	public function test_page_output_retains_store_properties(): void {
+		$output = $this->render_analytics_data();
+
+		foreach ( array( 'timezone', 'wp_version', 'store_currency' ) as $property ) {
+			$this->assertStringContainsString(
+				'"' . $property . '"',
+				$output,
+				sprintf( 'The store-level "%s" property should still be sent to the client.', $property )
+			);
+		}
+	}
+
+	/**
+	 * Test that a breadcrumb title cannot inflate the page. Titles come from
+	 * `post_title`, which core does not bound.
+	 */
+	public function test_page_output_caps_long_breadcrumb_titles(): void {
+		$breadcrumbs = $this->render_breadcrumbs_for_title( str_repeat( 'A', 5000 ) );
+
+		$this->assertSame(
+			array( str_repeat( 'A', 199 ) . '…' ),
+			$breadcrumbs,
+			'A breadcrumb title should be capped before it reaches cacheable page output.'
+		);
+	}
+
+	/**
+	 * Test that the cap counts characters rather than bytes. A byte-wise cut
+	 * splits the final UTF-8 sequence, which `wp_json_encode()` silently rewrites
+	 * to a replacement character.
+	 */
+	public function test_page_output_caps_breadcrumb_titles_by_character(): void {
+		$breadcrumbs = $this->render_breadcrumbs_for_title( str_repeat( '日', 250 ) );
+
+		$this->assertSame(
+			array( str_repeat( '日', 199 ) . '…' ),
+			$breadcrumbs,
+			'A breadcrumb title should be capped by character, not by byte.'
+		);
+	}
+
+	/**
+	 * Test that a title sitting exactly on the limit is not truncated.
+	 */
+	public function test_page_output_keeps_breadcrumb_titles_at_the_limit_intact(): void {
+		$title = str_repeat( 'A', 200 );
+
+		$this->assertSame(
+			array( $title ),
+			$this->render_breadcrumbs_for_title( $title ),
+			'A title exactly at the limit should be sent unchanged.'
+		);
+	}
+
+	/**
+	 * Test that a breadcrumb title short enough to keep is left untouched.
+	 */
+	public function test_page_output_keeps_short_breadcrumb_titles_intact(): void {
+		$title = 'Perfectly Ordinary Product Name';
+
+		$this->assertSame(
+			array( $title ),
+			$this->render_breadcrumbs_for_title( $title ),
+			'Ordinary titles should be sent unchanged.'
+		);
+	}
+
+	/**
+	 * Test that proxy mode still sends an empty payload. It reaches the same
+	 * cacheable markup, and the pixel is fired server-side from the visitor's
+	 * own uncached request instead.
+	 */
+	public function test_page_output_is_empty_under_proxy_tracking(): void {
+		add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+
+		$output = $this->render_analytics_data();
+
+		remove_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+
+		$this->assertStringContainsString(
+			'wcAnalytics.commonProps = [];',
+			$output,
+			'Proxy mode should send no common properties to the client.'
+		);
+	}
+
+	/**
+	 * Test that the server-fired pixel path keeps its request-derived
+	 * properties. That path runs on uncached requests and is the only place
+	 * where pixel.wp.com can learn the visitor's real IP, user agent and
+	 * referrer, so narrowing the page output must not narrow it too.
+	 */
+	public function test_server_fired_properties_retain_request_details(): void {
+		$properties = WC_Analytics_Tracking::get_common_properties();
+
+		foreach ( self::REQUEST_DERIVED_PROPERTIES as $property ) {
+			$this->assertArrayHasKey(
+				$property,
+				$properties,
+				sprintf( 'The server-fired pixel path still needs the "%s" property.', $property )
+			);
+		}
+
+		$this->assertSame( self::POISONED_HEADERS['HTTP_CF_CONNECTING_IP'], $properties['_via_ip'] );
+		$this->assertSame( self::POISONED_HEADERS['HTTP_USER_AGENT'], $properties['_via_ua'] );
+		$this->assertSame( self::POISONED_HEADERS['HTTP_REFERER'], $properties['_via_ref'] );
+	}
+
+	/**
+	 * Test that a long search term is capped before it is queued for the page. The
+	 * term reaches the pixel twice — as `search_query` and inside the browser's own
+	 * `_dl` — so an uncapped term can push the URL past what a server will accept.
+	 *
+	 * 1500 characters is deliberately under the 1600 bytes above which
+	 * `WP_Query::parse_query()` blanks `s`, so this is an input a request can
+	 * actually produce.
+	 */
+	public function test_queued_search_event_caps_long_search_query(): void {
+		$this->assertSame(
+			str_repeat( 'A', 199 ) . '…',
+			$this->capture_search_event( str_repeat( 'A', 1500 ) ),
+			'A search term should be capped before it is queued into page output.'
+		);
+	}
+
+	/**
+	 * Test that an ordinary search term is queued unchanged.
+	 */
+	public function test_queued_search_event_keeps_ordinary_search_query_intact(): void {
+		$term = 'blue cotton t-shirt';
+
+		$this->assertSame(
+			$term,
+			$this->capture_search_event( $term ),
+			'Ordinary search terms should be recorded unchanged.'
+		);
+	}
+
+	/**
+	 * Run a search request through `capture_search_query()` and return the
+	 * `search_query` property it queued for the page.
+	 *
+	 * @param string $term The search term the visitor requested.
+	 * @return string The queued search query.
+	 */
+	private function capture_search_event( string $term ): string {
+		global $wp_query;
+
+		$wp_query->is_search = true;
+		$wp_query->set( 's', $term );
+
+		$universal = new Universal();
+		$universal->capture_search_query();
+
+		$queue = WC_Analytics_Tracking::get_event_queue();
+		$this->assertNotEmpty( $queue, 'A search event should have been queued.' );
+
+		$event = end( $queue );
+		$this->assertSame( 'search', $event['eventName'], 'The queued event should be the search event.' );
+
+		return (string) ( $event['props']['search_query'] ?? '' );
+	}
+
+	/**
+	 * Render the injected analytics script and return its markup.
+	 *
+	 * @return string The markup written by `inject_analytics_data()`.
+	 */
+	private function render_analytics_data(): string {
+		$universal = new Universal();
+
+		ob_start();
+		$universal->inject_analytics_data();
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Render the page payload for a post with the given title.
+	 *
+	 * @param string $title The post title the breadcrumb trail is built from.
+	 * @return array The decoded breadcrumb titles.
+	 */
+	private function render_breadcrumbs_for_title( string $title ): array {
+		$GLOBALS['post'] = new \WP_Post(
+			(object) array(
+				'ID'         => 1,
+				'post_title' => $title,
+				'post_type'  => 'post',
+				'filter'     => 'raw',
+			)
+		);
+
+		return $this->get_rendered_breadcrumbs( $this->render_analytics_data() );
+	}
+
+	/**
+	 * Pull the breadcrumb trail back out of the rendered markup.
+	 *
+	 * @param string $output The markup written by `inject_analytics_data()`.
+	 * @return array The decoded breadcrumb titles.
+	 */
+	private function get_rendered_breadcrumbs( string $output ): array {
+		$assignment = 'wcAnalytics.breadcrumbs = ';
+
+		$start = strpos( $output, $assignment );
+		$this->assertNotFalse( $start, 'The rendered markup should assign wcAnalytics.breadcrumbs.' );
+
+		$start += strlen( $assignment );
+		$end    = strpos( $output, "\n", $start );
+		$this->assertNotFalse( $end, 'The breadcrumb assignment should end with a line break.' );
+
+		// Take the rest of the line and drop the statement's trailing semicolon.
+		$json = rtrim( trim( substr( $output, $start, $end - $start ) ), ';' );
+
+		return (array) json_decode( $json, true );
+	}
+
+	/**
+	 * Clear the `event_queue` static so a queued event cannot leak into another
+	 * test's rendered output.
+	 */
+	private function reset_event_queue(): void {
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+		$property   = $reflection->getProperty( 'event_queue' );
+		$property->setAccessible( true );
+		$property->setValue( null, array() );
+	}
+
+	/**
+	 * Clear the `cached_ip` static so a test's headers are resolved rather than
+	 * a value another test left behind.
+	 */
+	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 );
+	}
+}
diff --git a/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php b/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php
index 332879ea15e..379aaa63aa3 100644
--- a/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php
+++ b/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php
@@ -54,3 +54,25 @@ if ( ! function_exists( 'WC' ) ) {
 		};
 	}
 }
+
+if ( ! function_exists( 'is_cart' ) ) {
+	/**
+	 * Mock is_cart function.
+	 *
+	 * @return bool Always false; tests exercise generic front-end pages.
+	 */
+	function is_cart() {
+		return false;
+	}
+}
+
+if ( ! function_exists( 'is_account_page' ) ) {
+	/**
+	 * Mock is_account_page function.
+	 *
+	 * @return bool Always false; tests exercise generic front-end pages.
+	 */
+	function is_account_page() {
+		return false;
+	}
+}