PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | jetpack_vendor/automattic/woocommerce-analytics/src/class-wc-analytics-tracking.php +524 -34 16.2 → 16.3 View file →
@@ -11,8 +11,9 @@
11 11 namespace Automattic\Woocommerce_Analytics;
12 12
13 13 use Automattic\Jetpack\Device_Detection;
14 14 use Automattic\Jetpack\Device_Detection\User_Agent_Info;
15 +use Automattic\Woocommerce_Analytics;
15 16 use WP_Error;
16 17
17 18 /**
18 19 * WooCommerce Analytics Tracking class
@@ -32,8 +33,109 @@
32 33 */
33 34 const DAILY_SALT_OPTION = 'woocommerce_analytics_daily_salt';
34 35
35 36 /**
37 + * Property names a client is authoritative for on the proxy path.
38 + *
39 + * The server's own values for these describe the /track request, not the page
40 + * the event happened on. `_via_ref` is excluded despite sharing a header with
41 + * `_dr`: it records what fired the pixel, and is not used for page attribution.
42 + *
43 + * @since 0.18.0
44 + *
45 + * @var string[]
46 + */
47 + const CLIENT_OVERRIDABLE_PROPERTIES = array( '_lg', '_dl', '_dr' );
48 +
49 + /**
50 + * Identity and envelope property names a client may never set.
51 + *
52 + * Each is already protected by merge ordering in `get_properties()` or by
53 + * `Pixel_Builder::validate_and_sanitize()`. Listed anyway so that neither is
54 + * the only thing standing between a client and the visitor id.
55 + *
56 + * @since 0.18.0
57 + *
58 + * @var string[]
59 + */
60 + const RESERVED_IDENTITY_PROPERTIES = array( '_ui', '_ut', '_en', '_ts', 'browser_type' );
61 +
62 + /**
63 + * Maximum number of events a single client request may record.
64 + *
65 + * Prevents the unauthenticated endpoint from creating unbounded pixel requests.
66 + *
67 + * @since 0.18.0
68 + *
69 + * @var int
70 + */
71 + const MAX_CLIENT_EVENTS_PER_REQUEST = 50;
72 +
73 + /**
74 + * Maximum number of properties a client may set on one event.
75 + *
76 + * @since 0.18.0
77 + *
78 + * @var int
79 + */
80 + const MAX_CLIENT_PROPERTIES_PER_EVENT = 50;
81 +
82 + /**
83 + * Maximum length of a single property value bound for the pixel URL.
84 + *
85 + * The payload limit still caps the full event, while this preserves attribution URLs.
86 + *
87 + * @since 0.18.0
88 + *
89 + * @var int
90 + */
91 + const MAX_CLIENT_PROPERTY_LENGTH = 1000;
92 +
93 + /**
94 + * Maximum length of a client-supplied event or property name.
95 + *
96 + * `Pixel_Builder` validates characters but not length.
97 + *
98 + * @since 0.18.0
99 + *
100 + * @var int
101 + */
102 + const MAX_CLIENT_NAME_LENGTH = 100;
103 +
104 + /**
105 + * Maximum number of members in a client-supplied array value.
106 + *
107 + * Avoids excessive work while fitting an array into the payload budget.
108 + *
109 + * @since 0.18.0
110 + *
111 + * @var int
112 + */
113 + const MAX_CLIENT_ARRAY_MEMBERS = 50;
114 +
115 + /**
116 + * Maximum total length of one event's client-supplied properties.
117 + *
118 + * Counts percent-encoded URL bytes, which can exceed a value's character count.
119 + *
120 + * @since 0.18.0
121 + *
122 + * @var int
123 + */
124 + const MAX_CLIENT_PAYLOAD_LENGTH = 4096;
125 +
126 + /**
127 + * Maximum length of a pixel URL this package will fire.
128 + *
129 + * This also bounds properties added after client properties are sanitized.
130 + *
131 + * @since 0.18.0
132 + *
133 + * @var int
134 + */
135 + const MAX_PIXEL_URL_LENGTH = 8192;
136 +
137 + /**
36 138 * Event queue.
37 139 *
38 140 * @var array
39 141 */
@@ -67,17 +169,31 @@
67 169 */
68 170 private static $cached_visitor_id = null;
69 171
70 172 /**
173 + * Memoized reserved property names for the current request.
174 + *
175 + * @var string[]|null
176 + */
177 + private static $reserved_property_names = null;
178 +
179 + /**
71 180 * Record an event in Tracks and ClickHouse (If enabled).
72 181 *
182 + * @since 0.18.0 Added the `$is_client_supplied` parameter.
183 + *
73 184 * @param string $event_name The name of the event.
74 185 * @param array $event_properties Custom properties to send with the event.
186 + * @param bool $is_client_supplied Whether $event_properties came from an untrusted
187 + * client. Reserved property names are stripped and the
188 + * rest are bounded when true. Defaults to false for
189 + * server-side callers.
75 190 *
76 - * @return bool|WP_Error True on emit or deliberate skip (no consent, bot UA,
77 - * or cookie-less context); WP_Error if pixel firing failed.
191 + * @return bool|WP_Error True on emit or deliberate skip (no consent, bot UA, or
192 + * cookie-less context); WP_Error for an unusable client
193 + * event name, or if the pixel could not be built or fired.
78 194 */
79 - public static function record_event( $event_name, $event_properties = array() ) {
195 + public static function record_event( $event_name, $event_properties = array(), $is_client_supplied = false ) {
80 196 // Check consent before recording any event.
81 197 if ( ! Consent_Manager::has_analytics_consent() ) {
82 198 return true; // Skip recording.
83 199 }
@@ -91,10 +207,19 @@
91 207 if ( empty( self::get_visitor_id() ) ) {
92 208 return true;
93 209 }
94 210
211 + if ( $is_client_supplied ) {
212 + // Report invalid names because they cannot produce an event.
213 + if ( ! self::is_valid_client_name( $event_name ) ) {
214 + return new WP_Error( 'invalid_event_name', 'the event name is empty, too long, or not a string', 400 );
215 + }
216 +
217 + $event_properties = self::sanitize_client_properties( $event_properties );
218 + }
219 +
95 220 $prefixed_event_name = self::PREFIX . $event_name;
96 - $properties = self::get_properties( $prefixed_event_name, $event_properties );
221 + $properties = self::get_properties( $prefixed_event_name, $event_properties, $is_client_supplied );
97 222
98 223 // Record Tracks event.
99 224 $tracks_error = null;
100 225 $tracks_result = self::record_tracks_event( $properties );
@@ -123,8 +248,26 @@
123 248 return true;
124 249 }
125 250
126 251 /**
252 + * Record an event whose properties came from an untrusted client.
253 + *
254 + * The entry point for the tracking proxy: the REST controller and the MU-plugin
255 + * speed module both come through here. A distinct method rather than a sanitizer
256 + * callers must remember to invoke, so a wrong choice is visible at the call site.
257 + *
258 + * @since 0.18.0
259 + *
260 + * @param string $event_name The name of the event.
261 + * @param array $event_properties Client-supplied properties.
262 + *
263 + * @return bool|WP_Error True on emit or deliberate skip; WP_Error if pixel firing failed.
264 + */
265 + public static function record_client_event( $event_name, $event_properties = array() ) {
266 + return self::record_event( $event_name, $event_properties, true );
267 + }
268 +
269 + /**
127 270 * Queue an event in the event queue which will be processed on the page load in client-side analytics.
128 271 *
129 272 * @param string $event_name The name of the event.
130 273 * @param array $properties The event properties.
@@ -187,8 +330,28 @@
187 330 if ( empty( $pixel_url ) ) {
188 331 return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 );
189 332 }
190 333
334 + if ( strlen( $pixel_url ) > self::MAX_PIXEL_URL_LENGTH ) {
335 + // The proxy endpoint reports this error back to its caller, but no
336 + // first-party call site checks the return value, so for those events the
337 + // log line is the only signal that one was dropped.
338 + $error_message = sprintf(
339 + 'WooCommerce Analytics: dropped a %d byte pixel, over the %d byte limit.',
340 + strlen( $pixel_url ),
341 + self::MAX_PIXEL_URL_LENGTH
342 + );
343 + if ( function_exists( 'wc_get_logger' ) ) {
344 + wc_get_logger()->warning( $error_message, array( 'source' => 'woocommerce-analytics' ) );
345 + } else {
346 + // Fallback for MU-plugin stage when WooCommerce logger is not available.
347 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
348 + error_log( $error_message );
349 + }
350 +
351 + return new WP_Error( 'pixel_too_long', 'tracks pixel URL exceeds the maximum length', 400 );
352 + }
353 +
191 354 // Check if batching is supported.
192 355 $can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
193 356 || ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
194 357
@@ -266,12 +429,13 @@
266 429 */
267 430 private static function get_session_properties() {
268 431 $session_details = self::get_session_details();
269 432
433 + // The client-writable cookie also affects first-party events.
270 434 return array(
271 - 'session_id' => $session_details['session_id'] ?? null,
272 - 'landing_page' => $session_details['landing_page'] ?? null,
273 - 'is_engaged' => $session_details['is_engaged'] ?? null,
435 + 'session_id' => self::cap_property_value( $session_details['session_id'] ?? null ),
436 + 'landing_page' => self::cap_json_list_value( $session_details['landing_page'] ?? null ),
437 + 'is_engaged' => self::cap_property_value( $session_details['is_engaged'] ?? null ),
274 438 );
275 439 }
276 440
277 441 /**
@@ -295,19 +459,20 @@
295 459 $blog_user_id = self::get_blog_user_id();
296 460 $blog_details = self::get_blog_details();
297 461
298 462 return array(
299 - 'ui' => $blog_user_id,
300 - 'blog_id' => $blog_details['blog_id'] ?? null,
301 - 'store_id' => $blog_details['store_id'] ?? null,
302 - 'url' => $blog_details['url'] ?? null,
303 - 'woo_version' => $blog_details['wc_version'] ?? null,
304 - 'wp_version' => get_bloginfo( 'version' ),
305 - 'store_admin' => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
306 - 'device' => self::get_device_type(),
307 - 'store_currency' => $blog_details['store_currency'] ?? null,
308 - 'timezone' => wp_timezone_string(),
309 - 'is_guest' => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
463 + 'ui' => $blog_user_id,
464 + 'blog_id' => $blog_details['blog_id'] ?? null,
465 + 'store_id' => $blog_details['store_id'] ?? null,
466 + 'url' => $blog_details['url'] ?? null,
467 + 'woo_version' => $blog_details['wc_version'] ?? null,
468 + 'wp_version' => get_bloginfo( 'version' ),
469 + 'store_admin' => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
470 + 'device' => self::get_device_type(),
471 + 'store_currency' => $blog_details['store_currency'] ?? null,
472 + 'timezone' => wp_timezone_string(),
473 + 'is_guest' => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
474 + 'package_version' => Woocommerce_Analytics::PACKAGE_VERSION,
310 475 );
311 476 }
312 477
313 478 /**
@@ -312,26 +477,50 @@
312 477
313 478 /**
314 479 * Get all properties for the event including filtered and identity properties.
315 480 *
481 + * @since 0.18.0 Added the `$is_client_supplied` parameter.
482 + *
316 483 * @param string $event_name Event name.
317 484 * @param array $event_properties Event specific properties.
485 + * @param bool $is_client_supplied Whether $event_properties came from an untrusted client.
318 486 * @return array
319 487 */
320 - public static function get_properties( $event_name, $event_properties ) {
488 + public static function get_properties( $event_name, $event_properties, $is_client_supplied = false ) {
321 489 $common_properties = self::get_common_properties();
322 490
323 491 /**
324 492 * Allow defining custom event properties in WooCommerce Analytics.
325 493 *
494 + * On the proxy path (`$is_client_supplied`) a reserved name a callback returns
495 + * is discarded, because the server re-asserts its own value below. Names a
496 + * callback introduces are not reserved and are kept.
497 + *
326 498 * @module woocommerce-analytics
327 499 *
328 500 * @since 12.5
501 + * @since 0.18.0 Added the `$is_client_supplied` parameter.
329 502 *
330 - * @param array $all_props Array of event props to be filtered.
503 + * @param array $all_props Array of event props to be filtered.
504 + * @param string $event_name Event name.
505 + * @param bool $is_client_supplied Whether the props came from an untrusted client.
331 506 */
332 - $properties = apply_filters( 'jetpack_woocommerce_analytics_event_props', array_merge( $common_properties, $event_properties ), $event_name );
507 + $properties = apply_filters(
508 + 'jetpack_woocommerce_analytics_event_props',
509 + array_merge( $common_properties, $event_properties ),
510 + $event_name,
511 + $is_client_supplied
512 + );
333 513
514 + if ( $is_client_supplied ) {
515 + // A callback that defers to an existing value hands a reserved property
516 + // back to the client, which supplied it. Re-assert the server's own.
517 + $properties = array_merge(
518 + $properties,
519 + array_intersect_key( $common_properties, array_flip( self::get_reserved_property_names() ) )
520 + );
521 + }
522 +
334 523 $required_properties = $event_name
335 524 ? array(
336 525 '_en' => $event_name,
337 526 '_ts' => Pixel_Builder::build_timestamp(),
@@ -341,34 +530,327 @@
341 530 : array();
342 531
343 532 $all_properties = array_merge( $properties, $required_properties );
344 533
345 - // Convert array values to a comma-separated string and URL-encode them to ensure compatibility with JavaScript's encodeURIComponent() for pixel URL transmission.
346 534 foreach ( $all_properties as $key => $value ) {
347 - if ( ! is_array( $value ) ) {
535 + $all_properties[ $key ] = self::flatten_property_value( $value );
536 + }
537 +
538 + return $all_properties;
539 + }
540 +
541 + /**
542 + * Get the property names a client may not set.
543 + *
544 + * Derived from `get_common_properties()` rather than restated as a literal, so a
545 + * newly added common property is protected with no edit here. The pinned list in
546 + * `WC_Analytics_Tracking_Reserved_Props_Test` still fails on the addition, on
547 + * purpose: protection is automatic, granting an exemption is not. Memoized because
548 + * a batch would otherwise recompute the common properties once per event.
549 + *
550 + * @since 0.18.0
551 + *
552 + * @return string[] Reserved property names.
553 + */
554 + public static function get_reserved_property_names() {
555 + if ( null !== self::$reserved_property_names ) {
556 + return self::$reserved_property_names;
557 + }
558 +
559 + $server_owned = array_diff(
560 + array_keys( self::get_common_properties() ),
561 + self::CLIENT_OVERRIDABLE_PROPERTIES
562 + );
563 +
564 + self::$reserved_property_names = array_values(
565 + array_unique( array_merge( $server_owned, self::RESERVED_IDENTITY_PROPERTIES ) )
566 + );
567 +
568 + return self::$reserved_property_names;
569 + }
570 +
571 + /**
572 + * Remove server-owned properties from a client-supplied property array.
573 + *
574 + * Stripping is silent and the event still records: rejecting it would turn the
575 + * endpoint into an oracle for probing the reserved list.
576 + *
577 + * @since 0.18.0
578 + *
579 + * @param array $event_properties Client-supplied properties. A non-array is
580 + * tolerated, since the REST body is attacker-shaped.
581 + * @return array Properties with reserved names removed; empty array for empty or
582 + * non-array input.
583 + */
584 + public static function strip_reserved_properties( $event_properties ) {
585 + if ( ! is_array( $event_properties ) || empty( $event_properties ) ) {
586 + return array();
587 + }
588 +
589 + return array_diff_key(
590 + $event_properties,
591 + array_flip( self::get_reserved_property_names() )
592 + );
593 + }
594 +
595 + /**
596 + * Strip and bound a client-supplied property array.
597 + *
598 + * Keep rejected properties silent so the unauthenticated endpoint cannot expose its limits.
599 + *
600 + * @since 0.18.0
601 + *
602 + * @param array $event_properties Client-supplied properties.
603 + * @return array Sanitized properties.
604 + */
605 + public static function sanitize_client_properties( $event_properties ) {
606 + $event_properties = self::strip_reserved_properties( $event_properties );
607 +
608 + if ( count( $event_properties ) > self::MAX_CLIENT_PROPERTIES_PER_EVENT ) {
609 + $event_properties = array_slice( $event_properties, 0, self::MAX_CLIENT_PROPERTIES_PER_EVENT, true );
610 + }
611 +
612 + $values = array();
613 + $costs = array();
614 +
615 + foreach ( $event_properties as $key => $value ) {
616 + // Dropped, not truncated: two long names could truncate to the same key.
617 + if ( ! self::is_valid_client_name( $key ) || ! Pixel_Builder::prop_name_is_valid( $key ) ) {
348 618 continue;
349 619 }
350 620
351 - if ( empty( $value ) ) {
352 - $all_properties[ $key ] = '';
353 - continue;
621 + // Arrays are flattened later by get_properties(); bound their members too.
622 + if ( is_array( $value ) ) {
623 + $value = array_map(
624 + array( __CLASS__, 'cap_property_value' ),
625 + array_slice( $value, 0, self::MAX_CLIENT_ARRAY_MEMBERS, true )
626 + );
627 + } else {
628 + $value = self::cap_property_value( $value );
354 629 }
355 630
356 - $is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
357 - if ( $is_indexed_array ) {
358 - $value_string = implode( ',', $value );
359 - $all_properties[ $key ] = rawurlencode( $value_string );
360 - continue;
631 + $values[ $key ] = $value;
632 + $costs[ $key ] = strlen( $key ) + self::measure_client_value( $value );
633 + }
634 +
635 + // Preserve more properties by fitting the cheapest values first.
636 + asort( $costs );
637 +
638 + $budget = self::MAX_CLIENT_PAYLOAD_LENGTH;
639 + $kept = array();
640 +
641 + foreach ( $costs as $key => $cost ) {
642 + $value = $values[ $key ];
643 +
644 + // Trim values to keep them when their encoded form exceeds the remaining budget.
645 + if ( $cost > $budget ) {
646 + $room = $budget - strlen( $key );
647 +
648 + $value = is_array( $value )
649 + ? self::fit_client_array( $value, $room )
650 + : self::fit_client_string( (string) $value, $room );
651 +
652 + if ( array() === $value || '' === $value ) {
653 + continue;
654 + }
655 +
656 + $cost = strlen( $key ) + self::measure_client_value( $value );
361 657 }
362 658
363 - // Serialize non-indexed arrays to JSON strings.
364 - $all_properties[ $key ] = wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
659 + $budget -= $cost;
660 + $kept[ $key ] = $value;
365 661 }
366 662
367 - return $all_properties;
663 + // Back into the order the caller sent, so the pixel is not reordered by cost.
664 + return array_replace( array_intersect_key( $values, $kept ), $kept );
368 665 }
369 666
370 667 /**
668 + * Whether a client-supplied event or property name is usable.
669 + *
670 + * Without the type check an array name reaches `PREFIX . $event_name` and writes
671 + * a PHP warning to the log, unauthenticated.
672 + *
673 + * @since 0.18.0
674 + *
675 + * @param mixed $name Client-supplied name.
676 + * @return bool True when the name is a non-empty string within the length bound.
677 + */
678 + private static function is_valid_client_name( $name ) {
679 + return is_string( $name )
680 + && '' !== $name
681 + && mb_strlen( $name ) <= self::MAX_CLIENT_NAME_LENGTH;
682 + }
683 +
684 + /**
685 + * Reduce one property value to the string that goes into the pixel URL.
686 + *
687 + * The payload budget uses this same conversion to measure array values accurately.
688 + *
689 + * @since 0.18.0
690 + *
691 + * @param mixed $value Property value.
692 + * @return mixed The scalar it serializes to; non-array values are returned as-is.
693 + */
694 + private static function flatten_property_value( $value ) {
695 + if ( ! is_array( $value ) ) {
696 + return $value;
697 + }
698 +
699 + if ( empty( $value ) ) {
700 + return '';
701 + }
702 +
703 + // Not URL-encoded here: http_build_query() in Pixel_Builder encodes the whole URL, so encoding twice stores `%2F` in Tracks.
704 + if ( array_keys( $value ) === range( 0, count( $value ) - 1 ) ) {
705 + return implode( ',', $value );
706 + }
707 +
708 + return wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
709 + }
710 +
711 + /**
712 + * Bytes one value contributes to the pixel URL.
713 + *
714 + * @since 0.18.0
715 + *
716 + * @param mixed $value Already-capped client value.
717 + * @return int Byte count after `flatten_property_value()` and the encoding
718 + * `http_build_query()` applies on top of it.
719 + */
720 + private static function measure_client_value( $value ) {
721 + // Match http_build_query()'s RFC1738 encoding.
722 + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.urlencode_urlencode -- Deliberate: mirrors http_build_query()'s RFC1738 encoding so the budget measures the bytes the finished URL carries.
723 + return strlen( urlencode( (string) self::flatten_property_value( $value ) ) );
724 + }
725 +
726 + /**
727 + * Trim a string value until it fits the remaining budget.
728 + *
729 + * Uses binary search because each candidate must be encoded again.
730 + *
731 + * @since 0.18.0
732 + *
733 + * @param string $value Already-capped value.
734 + * @param int $budget Bytes still available for this value.
735 + * @return string The longest prefix that fits, with an ellipsis; empty when
736 + * even one character does not.
737 + */
738 + private static function fit_client_string( $value, $budget ) {
739 + if ( $budget <= 0 ) {
740 + return '';
741 + }
742 +
743 + $low = 0;
744 + $high = mb_strlen( $value );
745 +
746 + while ( $low < $high ) {
747 + $mid = (int) ceil( ( $low + $high ) / 2 );
748 +
749 + if ( self::measure_client_value( self::truncate_value( $value, $mid ) ) <= $budget ) {
750 + $low = $mid;
751 + } else {
752 + $high = $mid - 1;
753 + }
754 + }
755 +
756 + return self::truncate_value( $value, $low );
757 + }
758 +
759 + /**
760 + * Drop trailing members until an array value fits the remaining budget.
761 + *
762 + * @since 0.18.0
763 + *
764 + * @param array $members Already-capped members.
765 + * @param int $budget Bytes still available for this value.
766 + * @return array Members that fit; empty when even one does not.
767 + */
768 + private static function fit_client_array( $members, $budget ) {
769 + while ( ! empty( $members ) && self::measure_client_value( $members ) > $budget ) {
770 + array_pop( $members );
771 + }
772 +
773 + return $members;
774 + }
775 +
776 + /**
777 + * Bound a value that carries a JSON list, without invalidating the JSON.
778 + *
779 + * Preserve valid JSON by removing trailing list entries instead of cutting text.
780 + *
781 + * @since 0.18.0
782 + *
783 + * @param mixed $value Caller-influenced value.
784 + * @return mixed Bounded value, still valid JSON when it arrived as JSON.
785 + */
786 + private static function cap_json_list_value( $value ) {
787 + if ( ! is_string( $value ) || mb_strlen( $value ) <= self::MAX_CLIENT_PROPERTY_LENGTH ) {
788 + return self::cap_property_value( $value );
789 + }
790 +
791 + $decoded = json_decode( $value, true );
792 + if ( ! is_array( $decoded ) ) {
793 + return self::cap_property_value( $value );
794 + }
795 +
796 + while ( ! empty( $decoded ) ) {
797 + $encoded = wp_json_encode( $decoded );
798 +
799 + if ( is_string( $encoded ) && mb_strlen( $encoded ) <= self::MAX_CLIENT_PROPERTY_LENGTH ) {
800 + return $encoded;
801 + }
802 +
803 + array_pop( $decoded );
804 + }
805 +
806 + return '[]';
807 + }
808 +
809 + /**
810 + * Bound one value on its way to the pixel URL.
811 + *
812 + * Arrays and objects become empty strings to avoid warnings during flattening.
813 + *
814 + * @since 0.18.0
815 + *
816 + * @param mixed $value Caller-influenced value.
817 + * @return mixed Bounded value.
818 + */
819 + private static function cap_property_value( $value ) {
820 + if ( is_array( $value ) || is_object( $value ) ) {
821 + return '';
822 + }
823 +
824 + if ( ! is_string( $value ) ) {
825 + return $value;
826 + }
827 +
828 + if ( mb_strlen( $value ) <= self::MAX_CLIENT_PROPERTY_LENGTH ) {
829 + return $value;
830 + }
831 +
832 + return self::truncate_value( $value, self::MAX_CLIENT_PROPERTY_LENGTH );
833 + }
834 +
835 + /**
836 + * Cut a value to a character count, marking that it was cut.
837 + *
838 + * @since 0.18.0
839 + *
840 + * @param string $value Value to cut.
841 + * @param int $length Characters the result may occupy, ellipsis included.
842 + * @return string The cut value, or an empty string when nothing fits.
843 + */
844 + private static function truncate_value( $value, $length ) {
845 + if ( $length <= 0 ) {
846 + return '';
847 + }
848 +
849 + return mb_substr( $value, 0, $length - 1 ) . '…';
850 + }
851 +
852 + /**
371 853 * Get the current user id.
372 854 *
373 855 * @return int The user ID, or 0 if not logged in.
374 856 */
@@ -422,8 +904,16 @@
422 904
423 905 // Add _via_ref (referrer) for backward compatibility.
424 906 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
425 907 $data['_via_ref'] = isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
908 +
909 + // Headers are caller-supplied, and the referer lands here twice. Uncapped, one
910 + // long Referer pushes the finished URL past MAX_PIXEL_URL_LENGTH and costs the
911 + // whole event; capped, it costs the tail of one value. `_lg` is already bounded
912 + // above and `_via_ip` is validated by get_user_ip_address().
913 + foreach ( array( '_via_ua', '_dr', '_dl', '_via_ref' ) as $key ) {
914 + $data[ $key ] = self::cap_property_value( $data[ $key ] );
915 + }
426 916
427 917 return $data;
428 918 }
429 919