$post * @param array $cookie * @param mixed $bot_score * @return array{presence:string,signals:string[],disclosure:string,bot_score:?int} */ public static function read_client( array $post, array $cookie, $bot_score, $now = null ) { $now = null === $now ? time() : (int) $now; $presence = self::pick( $post, $cookie, 'agent_stamp_presence', 'agent_stamp_w', $now ); $signals = self::pick( $post, $cookie, 'agent_stamp_signals', 'agent_stamp_s', $now ); $name = self::pick( $post, $cookie, 'agent_stamp_disclosure', 'agent_stamp_d', $now ); if ( function_exists( 'wp_unslash' ) ) { $presence = wp_unslash( $presence ); $signals = wp_unslash( $signals ); $name = wp_unslash( $name ); } return array( 'presence' => self::presence( $presence ), 'signals' => self::signals( $signals ), 'disclosure' => self::disclosure_name( $name, $post ), 'bot_score' => self::bot_score( $bot_score ), ); } /** * @param array{verdict:string,name:string,confidence:string,detail:string} $result * @param array{presence:string,signals:string[],disclosure:string,bot_score:?int} $client * @return array{verdict:string,name:string,confidence:string,detail:string,presence:string} */ public static function merge( array $result, array $client ) { $presence = isset( $client['presence'] ) ? (string) $client['presence'] : ''; $signals = isset( $client['signals'] ) && is_array( $client['signals'] ) ? $client['signals'] : array(); $named = isset( $client['disclosure'] ) ? (string) $client['disclosure'] : ''; $score = isset( $client['bot_score'] ) ? $client['bot_score'] : null; $result['presence'] = $presence; if ( self::signature_failed( $result ) ) { return $result; } if ( 'agent' === $result['verdict'] ) { if ( '' !== $named && $named !== $result['name'] ) { $result['detail'] .= ' Agent field said ' . $named . '.'; } return $result; } if ( '' !== $named && 'human' === $result['verdict'] ) { $result['verdict'] = 'agent'; $result['name'] = $named; $result['confidence'] = 'declared'; $result['detail'] = 'The checkout agent field said ' . $named . '. The request was not signed.'; return $result; } if ( 'human' === $result['verdict'] && self::is_unattended( $presence, $signals ) ) { $result['verdict'] = 'unattended'; $result['name'] = ''; $result['confidence'] = 'witness'; $result['detail'] = self::unattended_detail( $signals, $score ); return $result; } if ( 'human' === $result['verdict'] && 'present' === $presence && count( self::driving_signals( $signals ) ) >= 2 ) { $result['verdict'] = 'likely'; $result['name'] = ''; $result['confidence'] = 'signals'; $result['detail'] = self::likely_detail( self::driving_signals( $signals ) ); return $result; } if ( 'human' === $result['verdict'] && 'present' === $presence && in_array( 'mismatch', $signals, true ) ) { $result['detail'] .= ' Browser platform hints did not match the user-agent. A person used the checkout.'; } return $result; } /** * @param string $raw * @param array $post * @return string */ public static function disclosure_name( $raw, array $post ) { $raw = trim( (string) $raw ); if ( '' === $raw ) { return ''; } if ( preg_match( '/(?:^|[;(\s])Agent\/([A-Za-z][A-Za-z0-9._-]{0,40})/i', $raw, $match ) ) { $raw = $match[1]; } $raw = trim( $raw ); if ( ! preg_match( '/^[A-Za-z][A-Za-z0-9._ -]{0,39}$/', $raw ) ) { return ''; } if ( self::is_customer_value( $raw, $post ) || self::is_blocked_name( $raw ) ) { return ''; } return $raw; } /** * @param string $presence * @param string[] $signals * @return bool */ private static function is_unattended( $presence, array $signals ) { if ( in_array( 'webdriver', $signals, true ) ) { return true; } return 'unattended' === $presence; } /** * Signals that a program drove a real browser. Each one alone is common enough in people to be ignored. * * @param string[] $signals * @return string[] */ private static function driving_signals( array $signals ) { return array_values( array_intersect( $signals, array( 'nomouse', 'center', 'inserted', 'mismatch', 'webgl' ) ) ); } /** * @param string[] $signals * @return string */ private static function likely_detail( array $signals ) { $text = array( 'nomouse' => 'The place-order click came with almost no mouse movement on the page.', 'center' => 'The click landed on the exact center of the button.', 'inserted' => 'Several fields were filled with no keystrokes, no paste and no browser autofill.', 'mismatch' => 'The user-agent and the browser platform disagree.', 'webgl' => 'The graphics renderer does not match the user-agent.', ); $detail = 'A trusted click placed the order, and the page saw signs that a program drove the browser.'; foreach ( $signals as $signal ) { $detail .= ' ' . $text[ $signal ]; } return $detail . ' This is likely, not proof. No agent named itself. Checkout was not blocked.'; } /** * @param string[] $signals * @param ?int $score * @return string */ private static function unattended_detail( array $signals, $score ) { if ( in_array( 'webdriver', $signals, true ) ) { $detail = 'The browser reported it is automated.'; } else { $detail = 'The checkout page loaded and no person used the place-order control.'; } if ( in_array( 'mismatch', $signals, true ) ) { $detail .= ' The user-agent and the browser platform disagree.'; } if ( in_array( 'webgl', $signals, true ) ) { $detail .= ' The graphics renderer does not match the user-agent.'; } if ( is_int( $score ) && $score <= 30 ) { $detail .= ' The host bot score was ' . $score . '.'; } return $detail . ' Checkout was not blocked.'; } /** * @param array{verdict:string,detail:string} $result * @return bool */ private static function signature_failed( array $result ) { if ( 'unknown' !== $result['verdict'] ) { return false; } $detail = isset( $result['detail'] ) ? $result['detail'] : ''; return false !== stripos( $detail, 'Signature' ) || false !== stripos( $detail, 'not treated as proof' ); } /** * @param array $post * @param array $cookie * @param string $post_key * @param string $cookie_key * @param int $now * @return string */ private static function pick( array $post, array $cookie, $post_key, $cookie_key, $now ) { if ( array_key_exists( $post_key, $post ) && is_string( $post[ $post_key ] ) ) { return $post[ $post_key ]; } if ( ! self::cookie_is_fresh( $cookie, $now ) ) { return ''; } if ( isset( $cookie[ $cookie_key ] ) && is_string( $cookie[ $cookie_key ] ) ) { return $cookie[ $cookie_key ]; } return ''; } /** * Checkout cookies are written at submit. An older visit must not label this order. * * @param array $cookie * @param int $now * @return bool */ private static function cookie_is_fresh( array $cookie, $now ) { if ( ! isset( $cookie['agent_stamp_t'] ) || ! is_string( $cookie['agent_stamp_t'] ) ) { return false; } $issued = $cookie['agent_stamp_t']; if ( function_exists( 'wp_unslash' ) ) { $issued = wp_unslash( $issued ); } if ( ! preg_match( '/^[0-9]{10}$/', $issued ) ) { return false; } $age = $now - (int) $issued; return $age >= -120 && $age <= 180; } /** * @param string $presence * @return string */ private static function presence( $presence ) { $presence = strtolower( trim( (string) $presence ) ); if ( 'express' === $presence ) { return 'present'; } if ( in_array( $presence, array( 'present', 'unattended' ), true ) ) { return $presence; } return ''; } /** * @param string $raw * @return string[] */ private static function signals( $raw ) { $allowed = array( 'webdriver', 'mismatch', 'webgl', 'nomouse', 'center', 'inserted' ); $found = array(); foreach ( explode( ',', strtolower( (string) $raw ) ) as $part ) { $part = trim( $part ); if ( in_array( $part, $allowed, true ) && ! in_array( $part, $found, true ) ) { $found[] = $part; } } return $found; } /** * @param mixed $score * @return ?int */ private static function bot_score( $score ) { if ( is_int( $score ) && $score >= 1 && $score <= 99 ) { return $score; } if ( is_string( $score ) && preg_match( '/^[1-9][0-9]?$/', $score ) ) { return (int) $score; } return null; } /** * @param string $name * @param array $post * @return bool */ private static function is_customer_value( $name, array $post ) { foreach ( array( 'billing_first_name', 'billing_last_name', 'shipping_first_name', 'shipping_last_name' ) as $key ) { if ( ! isset( $post[ $key ] ) || ! is_string( $post[ $key ] ) ) { continue; } $customer = trim( function_exists( 'wp_unslash' ) ? wp_unslash( $post[ $key ] ) : $post[ $key ] ); if ( '' !== $customer && 0 === strcasecmp( $name, $customer ) ) { return true; } } return false; } /** * @param string $name * @return bool */ private static function is_blocked_name( $name ) { $blocked = array( 'chrome', 'safari', 'firefox', 'mozilla', 'windows', 'macintosh', 'linux', 'android', 'iphone', 'ipad', 'human', 'unknown', 'agent', 'edge', 'opera', 'samsung', 'mobile', 'google', 'bot', ); return in_array( strtolower( $name ), $blocked, true ); } }