on the page. If that is * also empty (e.g. Elementor AJAX didn't include it), falls back to * the server-side transient created by the phone-home AJAX call. * * @return array Sanitized UTM data, or empty array if no data available. */ public static function get_data() { // Primary: read from cookie. if ( isset( $_COOKIE[ self::COOKIE_NAME ] ) ) { $raw_value = wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $data = json_decode( $raw_value, true ); if ( is_array( $data ) && ! empty( $data ) ) { return self::sanitize_data( $data ); } } // Fallback 1: read from hidden form field POST data. // This catches Safari ITP / private browsing where cookies are // blocked after ad click redirects. The JS injects a hidden // into every form with the // tracking JSON, so it comes through in the POST body. if ( isset( $_POST[ self::POST_FIELD_NAME ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Tracking data embedded in third-party form submissions; nonce handled by each form plugin. $raw_post = wp_unslash( $_POST[ self::POST_FIELD_NAME ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput $data = json_decode( $raw_post, true ); if ( is_array( $data ) && ! empty( $data ) ) { return self::sanitize_data( $data ); } } // Fallback 2: read from server-side transient (phone-home data). // When the JS captures UTM params, it sends them to a WordPress // AJAX endpoint which stores them in a transient keyed by the // visitor's IP + user agent hash. This works even when: // - Cookies are blocked (Safari ITP private browsing) // - The form plugin submits via AJAX without our hidden field // (Elementor Pro, potentially Ninja Forms) $visitor_hash = self::get_visitor_hash(); $transient = get_transient( self::TRANSIENT_PREFIX . $visitor_hash ); if ( $transient ) { $data = json_decode( $transient, true ); if ( is_array( $data ) && ! empty( $data ) ) { return self::sanitize_data( $data ); } } return array(); } /** * Get a Single Value * * @param string $key The parameter name. * @param string $default Default value if the key doesn't exist. * * @return string The sanitized value, or the default. */ public static function get( $key, $default = '' ) { $data = self::get_data(); return isset( $data[ $key ] ) ? $data[ $key ] : $default; } /** * Check if Tracking Data Exists * * Returns true if any of the three data sources (cookie, POST, * or transient) contains tracking data. * * @return bool True if tracking data exists. */ public static function has_data() { $data = self::get_data(); return ! empty( $data ); } /** * Get Traffic Source Label * * @return array Detection result from FTCON_Detector::detect_source(). */ public static function get_detected_source() { $data = self::get_data(); if ( empty( $data ) ) { return array( 'traffic_source' => 'Direct', 'platform' => 'Direct', 'click_id' => '', 'click_id_type' => '', ); } return FTCON_Detector::detect_source( $data ); } /** * Max number of phone-home transients this site will create within * the rate-limit window. Bounds worst-case storage growth from a * client deliberately varying its User-Agent on every request (the * one part of the visitor hash it can freely control even once * spoofable proxy headers are ignored). * * @var int */ const MAX_TRANSIENTS_PER_WINDOW = 1000; /** * Rate-limit window, in seconds. * * @var int */ const RATE_LIMIT_WINDOW = 300; /** * Rate-limit counter option name. * * @var string */ const RATE_LIMIT_OPTION = 'ftcon_visit_transient_count'; /** * Generate Visitor Hash * * Creates a unique-ish hash for the current visitor based on their * IP address and user agent. Used as the transient key for the * phone-home fallback. * * Only REMOTE_ADDR is used for the IP component — the X-Forwarded-For * and Client-IP headers are client-supplied and trivially spoofable, * which would otherwise let an unauthenticated request mint an * unlimited number of distinct transient keys. * * Note: Two visitors behind the same NAT with the same browser * would share a hash, but for SMB sites this collision is extremely * unlikely within the 1-hour transient window. * * @return string A 32-character hex hash. */ public static function get_visitor_hash() { $ip = ! empty( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; return md5( $ip . '|' . $ua ); } /** * Check and Consume Rate Limit Budget * * Guards against a client minting unlimited distinct transient keys * (e.g. by sending a different User-Agent on every request) by * capping total phone-home transient creation site-wide within a * short rolling window. Once the cap is hit, new phone-home data is * simply not persisted until the window resets — tracking degrades * gracefully rather than allowing unbounded storage growth. * * @return bool True if under budget (caller may proceed to store), false if the limit was hit. */ public static function can_store_transient() { $count = (int) get_transient( self::RATE_LIMIT_OPTION ); if ( $count >= self::MAX_TRANSIENTS_PER_WINDOW ) { return false; } set_transient( self::RATE_LIMIT_OPTION, $count + 1, self::RATE_LIMIT_WINDOW ); return true; } /** * Allowed Tracking Data Keys * * Any key not in this list is dropped. Keeps untrusted payloads * (cookie, hidden field, or phone-home AJAX) bounded to exactly * what utm-capture.js is supposed to send, instead of persisting * arbitrary attacker-supplied keys. * * @var string[] */ const ALLOWED_KEYS = array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid', 'li_fat_id', 'msclkid', 'ttclid', 'epik', 'qclid', 'scclid', 'aclk', 'landing_page', 'referrer', 'page_url', 'timestamp', ); /** * Max length (characters) for any single tracking value once sanitized. * * @var int */ const MAX_VALUE_LENGTH = 500; /** * Sanitize Tracking Data * * Restricts the data to a known whitelist of keys, sanitizes every * value (URL fields with esc_url_raw(), everything else with * sanitize_text_field()), and caps each value's length. Used both * when data is first received (cookie, hidden field, phone-home * AJAX) and when it's read back out, so nothing unsanitized or * unbounded ever reaches storage. * * @param array $data Raw decoded tracking data (from cookie, POST, or transient). * * @return array Sanitized data. */ public static function sanitize_data( $data ) { $sanitized = array(); $url_fields = array( 'referrer', 'landing_page', 'page_url' ); foreach ( $data as $key => $value ) { if ( ! is_string( $value ) || ! is_string( $key ) ) { continue; } $clean_key = sanitize_text_field( $key ); if ( ! in_array( $clean_key, self::ALLOWED_KEYS, true ) ) { continue; } $clean_value = in_array( $clean_key, $url_fields, true ) ? esc_url_raw( $value ) : sanitize_text_field( $value ); $sanitized[ $clean_key ] = mb_substr( $clean_value, 0, self::MAX_VALUE_LENGTH ); } return $sanitized; } }