true, 'checked' => time(), 'last_success' => time(), 'fails' => 0, 'reporters' => array(), 'error' => '', ) ), true ); } } /** * Ends the request with a status and no body. * * @param int $status - HTTP status. * * @return void * * @since 5.4.0 */ private static function finish( int $status ): void { http_response_code( $status ); exit; } /** * Whether the request comes from a page of this site - or says nothing about where it comes from. * * Browsers send Origin with these POSTs and pages cannot change it, * so another site cannot make its visitors' browsers report pageviews * here. Clients other than browsers can send anything - that is what * the rate limit is for. Missing Origin is allowed for legacy clients unless * Fetch Metadata reports cross-site; an explicit "null" origin is refused. * * @param string $site_host - Absolute site URL or normalized HTTP origin. * * @return bool * * @since 5.4.0 */ public static function same_origin( string $site_host ): bool { $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; // Legacy clients may omit Origin; an explicit opaque/cross-site origin is refused. if ( '' === $origin ) { return 'cross-site' !== ( $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '' ); } if ( ! is_string( $origin ) || 'null' === $origin ) { return false; } $actual = self::origin( $origin ); $expected = self::origin( $site_host ); return '' !== $actual && '' !== $expected && $actual === $expected; } /** Normalize an HTTP origin, including its effective port. */ private static function origin( string $url ): string { $p = parse_url( $url ); if ( ! is_array( $p ) || empty( $p['host'] ) || ! isset( $p['scheme'] ) || ! in_array( strtolower( $p['scheme'] ), array( 'https', 'http' ), true ) || isset( $p['user'] ) || isset( $p['pass'] ) ) { return ''; } $scheme = strtolower( $p['scheme'] ); return $scheme . '://' . strtolower( $p['host'] ) . ':' . ( $p['port'] ?? ( 'https' === $scheme ? 443 : 80 ) ); } /** * Whether the buffer has grown past MAX_BUFFER_BYTES - cron not emptying it. * * @return bool * * @since 5.4.0 */ public static function buffer_full(): bool { return Traffic_Buffer::size() > self::MAX_BUFFER_BYTES; } /** * Whether a stored config has what the endpoint needs. * * @param mixed $config - Value of the option. * * @return bool * * @phpstan-assert-if-true array{dir:string, host:string} $config * * @since 5.4.0 */ private static function is_config( $config ): bool { return is_array( $config ) && isset( $config['dir'], $config['host'] ) && is_string( $config['dir'] ) && '' !== $config['dir'] && is_string( $config['host'] ); } /* * ------------------------------------------------------------------ * Shared with admin-ajax.php. SHORTINIT-safe. * ------------------------------------------------------------------ */ /** * The token the tracker sends, '' when the site has no signing key. * * @param int $site - Blog ID the pageviews belong to. * * @return string * * @since 5.4.0 */ public static function token( int $site ): string { if ( '' === self::key() ) { return ''; } $day = intdiv( time(), DAY_IN_SECONDS ); return $day . '.' . self::token_mac( $site, $day ); } /** * Checks a token made by token() for this site, within the last TOKEN_DAYS days. * * @param string $token - As sent. * @param int $site - Blog ID the request is for. * * @return bool * * @since 5.4.0 */ public static function verify_token( string $token, int $site ): bool { if ( '' === self::key() || ! preg_match( '/^(\d{1,10})\.([a-f0-9]{32})$/D', $token, $m ) ) { return false; } $day = (int) $m[1]; $today = intdiv( time(), DAY_IN_SECONDS ); // A day ahead is allowed for clocks that differ between servers. if ( $day > $today + 1 || $day < $today - self::TOKEN_DAYS ) { return false; } return hash_equals( self::token_mac( $site, $day ), $m[2] ); } /** * Whether this IP has sent more than RATE_LIMIT pageviews in the last minute - counting this one. * * @param string $ip_hash - Hashed client IP. * * @return bool * * @since 5.4.0 */ public static function rate_limited( string $ip_hash ): bool { $dir = self::counter_dir(); $count = Rate_Limiter::increment_counter( 'traffic_' . hash( 'sha256', ABSPATH . '|' . \get_current_blog_id() . '|' . $ip_hash ), intdiv( time(), MINUTE_IN_SECONDS ), $dir ); // Storage failure must not turn an anonymous endpoint into an unlimited writer. return 0 === $count || $count > self::RATE_LIMIT; } /** A separate directory keeps counter cleanup independent of the security module. */ private static function counter_dir(): string { $dir = Traffic_Buffer::get_buffer_dir() . '/counters'; if ( ! ( defined( 'SHORTINIT' ) && SHORTINIT ) && ! is_dir( $dir ) ) { \wp_mkdir_p( $dir ); } return $dir; } /** Called by the traffic cron even when the security module is disabled. */ public static function cleanup_counters(): void { $dir = Traffic_Buffer::get_buffer_dir() . '/counters'; foreach ( glob( $dir . '/*.cnt' ) ?: array() as $file ) { if ( (int) @filemtime( $file ) < time() - 600 ) { @unlink( $file ); } } } /** * Turns a tracker request into a pageview record. * * Sets the new-visitor cookie when the record is kept. * * @param array $post - Request fields, unslashed. * @param string $user_agent - Raw User-Agent header. * @param string $ip_hash - Hashed client IP. * @param string $site_host - Host of the site, to drop referrers from its own pages. * * @return array|null The record, or null when the pageview is not to be stored (bot, excluded path). * * @since 5.4.0 */ public static function build_record( array $post, string $user_agent, string $ip_hash, string $site_host ): ?array { $user_agent = \sanitize_text_field( $user_agent ); if ( self::is_bot( $user_agent ) ) { return null; } $path = self::normalize_path( isset( $post['path'] ) && is_string( $post['path'] ) ? $post['path'] : '/' ); if ( self::is_excluded_path( $path ) ) { return null; } $referrer = self::normalize_referrer( isset( $post['referrer'] ) && is_string( $post['referrer'] ) ? $post['referrer'] : '', $site_host ); // JS tracker may send screen_width for future use. $screen_width = isset( $post['screen_width'] ) && is_string( $post['screen_width'] ) ? max( 0, min( 100000, (int) $post['screen_width'] ) ) : 0; $ua_info = self::parse_user_agent( $user_agent ); return array( 'date' => \current_time( 'Y-m-d' ), 'path' => $path, 'referrer' => $referrer, 'is_new' => self::is_new_visitor(), 'timestamp' => time(), 'device_type' => $ua_info['device_type'], 'browser' => $ua_info['browser'], 'os' => $ua_info['os'], 'screen_width' => $screen_width, 'ip_hash' => $ip_hash, ); } /** * A path as sent by the tracker, decoded and cleaned, with a leading slash. * * @param string $raw - Path and query string, possibly percent-encoded. * * @return string * * @since 5.4.0 */ public static function normalize_path( string $raw ): string { // Decode first: sanitize_text_field() strips %XX sequences, and paths are often Cyrillic, Chinese... $path = \sanitize_text_field( rawurldecode( $raw ) ); if ( '' === $path || '/' !== $path[0] ) { $path = '/' . $path; } return mb_substr( $path, 0, self::MAX_URL ); } /** * A referrer reduced to scheme, host, path and query - or '' for the site's own pages and anything that is not an http(s) URL. * * @param string $raw - document.referrer as sent. * @param string $site_host - Absolute site URL or normalized HTTP origin. * * @return string * * @since 5.4.0 */ public static function normalize_referrer( string $raw, string $site_host ): string { $raw = trim( $raw ); if ( '' === $raw || strlen( $raw ) > 4 * self::MAX_URL ) { return ''; } $parts = parse_url( $raw ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url() is not loaded under SHORTINIT. if ( ! is_array( $parts ) || ! isset( $parts['scheme'], $parts['host'] ) ) { return ''; } $scheme = strtolower( $parts['scheme'] ); $host = strtolower( $parts['host'] ); if ( ( 'http' !== $scheme && 'https' !== $scheme ) || ! preg_match( '/^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/D', $host ) ) { return ''; } if ( strtolower( $site_host ) === $host ) { return ''; } // Characters a URL may contain (RFC 3986), like esc_url_raw() keeps - which is not loaded here. $clean = static function ( string $part ): string { return (string) preg_replace( '/[^A-Za-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=%]/', '', $part ); }; $referrer = $scheme . '://' . $host; if ( isset( $parts['path'] ) && '/' !== $parts['path'] ) { $referrer .= $clean( $parts['path'] ); } if ( isset( $parts['query'] ) && '' !== $parts['query'] ) { $referrer .= '?' . $clean( $parts['query'] ); } return substr( $referrer, 0, self::MAX_URL ); } /** * Whether a User-Agent is a bot's. An empty one is. * * @param string $user_agent - User-Agent header. * * @return bool * * @since 5.4.0 */ public static function is_bot( string $user_agent ): bool { return '' === $user_agent || (bool) preg_match( self::BOT_PATTERN, $user_agent ); } /** * Check if a path should be excluded from tracking (non-content assets). * * Filters out favicon.ico, robots.txt, sitemaps, WP assets, etc. * * @param string $path Normalized path to check. * * @return bool * * @since 5.4.0 */ public static function is_excluded_path( string $path ): bool { return (bool) preg_match( self::EXCLUDED_PATHS_PATTERN, $path ); } /** * Determine if the current visitor is new (first visit today). * * Uses a daily cookie for privacy-friendly uniqueness. * * @return bool * * @since 5.4.0 */ public static function is_new_visitor(): bool { $cookie_name = self::COOKIE_NAME; if ( isset( $_COOKIE[ $cookie_name ] ) ) { return false; } // Set cookie to expire at midnight (local timezone). $tz = \wp_timezone(); $now = new \DateTime( 'now', $tz ); $midnight = new \DateTime( 'tomorrow midnight', $tz ); $seconds = $midnight->getTimestamp() - $now->getTimestamp(); // phpcs:ignore WordPress.WP.AlternativeFunctions setcookie( $cookie_name, '1', array( 'expires' => time() + $seconds, 'path' => '/', 'domain' => '', 'secure' => \is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); // Mark as set for this request. $_COOKIE[ $cookie_name ] = '1'; return true; } /** * Parse the User-Agent string to extract device type, browser, and OS. * * Lightweight regex-based parser covering the top browsers and operating * systems. Designed for speed over exhaustive coverage. * * @param string $ua Sanitized User-Agent string. * * @return array{device_type: string, browser: string, os: string} * * @since 5.4.0 */ public static function parse_user_agent( string $ua ): array { // --- Device type --- $device_type = 'desktop'; if ( preg_match( '/Mobile|Android.*Mobile|iPhone|iPod|Windows Phone|BlackBerry|Opera Mini|IEMobile/i', $ua ) ) { $device_type = 'mobile'; } elseif ( preg_match( '/iPad|Android(?!.*Mobile)|Tablet/i', $ua ) ) { $device_type = 'tablet'; } // --- Browser (order matters: specific forks before generic engines) --- $browser = 'Other'; if ( preg_match( '/Edg(?:e|A)?\/[\d]/i', $ua ) ) { $browser = 'Edge'; } elseif ( preg_match( '/OPR\/|Opera/i', $ua ) ) { $browser = 'Opera'; } elseif ( preg_match( '/SamsungBrowser/i', $ua ) ) { $browser = 'Samsung Internet'; } elseif ( preg_match( '/UCBrowser/i', $ua ) ) { $browser = 'UC Browser'; } elseif ( preg_match( '/YaBrowser/i', $ua ) ) { $browser = 'Yandex'; } elseif ( preg_match( '/Vivaldi/i', $ua ) ) { $browser = 'Vivaldi'; } elseif ( preg_match( '/Brave/i', $ua ) ) { $browser = 'Brave'; } elseif ( preg_match( '/Firefox|FxiOS/i', $ua ) ) { $browser = 'Firefox'; } elseif ( preg_match( '/CriOS/i', $ua ) ) { $browser = 'Chrome'; } elseif ( preg_match( '/Chrome/i', $ua ) && ! preg_match( '/Chromium/i', $ua ) ) { $browser = 'Chrome'; } elseif ( preg_match( '/Safari/i', $ua ) && ! preg_match( '/Chrome|Chromium/i', $ua ) ) { $browser = 'Safari'; } elseif ( preg_match( '/MSIE|Trident/i', $ua ) ) { $browser = 'IE'; } // --- Operating System --- $os = 'Other'; if ( preg_match( '/Windows NT/i', $ua ) ) { $os = 'Windows'; } elseif ( preg_match( '/Mac OS X|Macintosh/i', $ua ) ) { $os = 'macOS'; } elseif ( preg_match( '/CrOS/i', $ua ) ) { $os = 'Chrome OS'; } elseif ( preg_match( '/Android/i', $ua ) ) { $os = 'Android'; } elseif ( preg_match( '/iPhone|iPad|iPod/i', $ua ) ) { $os = 'iOS'; } elseif ( preg_match( '/Linux/i', $ua ) ) { $os = 'Linux'; } return array( 'device_type' => $device_type, 'browser' => $browser, 'os' => $os, ); } /** * The client's IP address - REMOTE_ADDR unless proxy headers are trusted. * * Same rules as Rate_Limiter::get_client_ip(), which needs the full runtime for the setting. * * @param bool $trust_proxy - The security_trust_proxy_headers setting. * * @return string * * @since 5.4.0 */ private static function client_ip( bool $trust_proxy ): string { if ( $trust_proxy ) { foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP' ) as $header ) { if ( empty( $_SERVER[ $header ] ) || ! is_string( $_SERVER[ $header ] ) ) { continue; } $ip = trim( explode( ',', \sanitize_text_field( $_SERVER[ $header ] ) )[0] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Not slashed under SHORTINIT. if ( false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return $ip; } } } return isset( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( $_SERVER['REMOTE_ADDR'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Not slashed under SHORTINIT. } /** * Hashed IP - the same value as Rate_Limiter::get_ip_hash(). * * @param string $ip - Client IP. * * @return string * * @since 5.4.0 */ private static function ip_hash( string $ip ): string { if ( '' === $ip ) { return ''; } return hash_hmac( 'sha256', $ip, defined( 'AUTH_SALT' ) ? AUTH_SALT : 'advan-fallback-salt-' . md5( ABSPATH ) ); } /** * MAC of a token. * * @param int $site - Blog ID. * @param int $day - Days since the epoch. * * @return string 32 hex characters. * * @since 5.4.0 */ private static function token_mac( int $site, int $day ): string { return substr( self::sign( 'collect|' . $site . '|' . $day ), 0, 32 ); } /** * HMAC of a message with the signing key. * * @param string $message - What to sign. * * @return string Hex. * * @since 5.4.0 */ private static function sign( string $message ): string { return hash_hmac( 'sha256', $message, self::key() ); } /** * Signing key, derived from AUTH_KEY and AUTH_SALT - '' when they are missing or still the sample ones. * * wp_salt() would be the usual source, but it is pluggable and not loaded under SHORTINIT. * * @return string * * @since 5.4.0 */ private static function key(): string { if ( null === self::$key ) { $secret = ( defined( 'AUTH_KEY' ) ? (string) AUTH_KEY : '' ) . ( defined( 'AUTH_SALT' ) ? (string) AUTH_SALT : '' ); self::$key = strlen( $secret ) < 32 || false !== strpos( $secret, 'put your unique phrase here' ) ? '' : hash_hmac( 'sha256', 'aadvana-traffic-collect', $secret ); } return self::$key; } /* * ------------------------------------------------------------------ * Full runtime: deciding whether the tracker may use the endpoint. * ------------------------------------------------------------------ */ /** * Whether the fast endpoint may be used at all on this site. * * Off with `define( 'ADVAN_TRAFFIC_FAST_COLLECT', false );` or the * `advan_traffic_fast_collect` filter, and when wp-config.php has no * real AUTH_KEY / AUTH_SALT to sign tokens with. * * @return bool * * @since 5.4.0 */ public static function is_allowed(): bool { $allowed = ! ( defined( 'ADVAN_TRAFFIC_FAST_COLLECT' ) && ! ADVAN_TRAFFIC_FAST_COLLECT ) && '' !== self::key(); return (bool) \apply_filters( 'advan_traffic_fast_collect', $allowed ); } /** * The endpoint URL for the tracker, or '' to use admin-ajax.php. * * '' only while the endpoint is known not to work - after browsers * reported failures in the last day. Otherwise the URL, also while * unverified: the tracker falls back by itself. The config is rewritten * when the plugin, the uploads directory, the site address or the * proxy setting changed, and after a five-minute cooldown to have browsers verify again. * The option is autoloaded, so an ordinary page view costs no query. * * @return string * * @since 5.4.0 */ public static function url(): string { if ( ! self::is_allowed() ) { self::disable(); return ''; } self::counter_dir(); $wanted = self::wanted_config(); $stored = \get_option( self::OPTION ); // Without 'fails' it was written by the earlier loopback self-test, whose failures do not count: bot filters refused it, not the server. $fresh = is_array( $stored ) && array_key_exists( 'fails', $stored ) && array_intersect_key( $stored, $wanted ) === $wanted; $state = $fresh ? ( $stored['ok'] ?? null ) : null; $recent = $fresh && (int) ( $stored['checked'] ?? 0 ) >= time() - self::RETEST_AFTER; if ( $recent && false === $state ) { return ''; } if ( ! $fresh || ( ! $recent && null !== $state ) ) { // The endpoint writes only into a directory that exists and is protected; this makes it so. if ( ! Traffic_Buffer::is_available() ) { \update_option( self::OPTION, $wanted + array( 'ok' => false, 'checked' => time(), 'fails' => 0, 'error' => \__( 'The traffic buffer directory in uploads is not writable.', '0-day-analytics' ), ), true ); return ''; } \update_option( self::OPTION, $wanted + array( 'ok' => null, 'checked' => 0, 'fails' => 0, 'error' => '', ), true ); } return self::endpoint_url(); } /** * Records that a browser got an error status from the endpoint and used admin-ajax.php instead. * * Recent successful requests override reports. Three distinct clients in a * five-minute interval can request a short cooldown, never a permanent disable. * Reports are not trusted proof that the origin is broken. * * @param int $status - HTTP status the browser got. * @param string $ip_hash - Hashed IP of the reporting visitor. * * @return void * * @since 5.4.0 */ public static function report_failure( int $status, string $ip_hash ): void { $stored = \get_option( self::OPTION ); if ( ! is_array( $stored ) || false === ( $stored['ok'] ?? null ) || (int) ( $stored['last_success'] ?? 0 ) >= time() - 60 || $status < 200 || $status > 599 || 204 === $status ) { return; } // Only distinct clients in the same five-minute interval count; never a permanent disable. if ( (int) ( $stored['failure_started'] ?? 0 ) < time() - 300 ) { $stored['reporters'] = array(); $stored['fails'] = 0; $stored['failure_started'] = time(); } $reporters = isset( $stored['reporters'] ) && is_array( $stored['reporters'] ) ? $stored['reporters'] : array(); $reporter = substr( $ip_hash, 0, 16 ); if ( in_array( $reporter, $reporters, true ) ) { return; } $stored['reporters'] = array_slice( array_merge( $reporters, array( $reporter ) ), -self::FAILURES_TO_DISABLE ); $stored['fails'] = (int) ( $stored['fails'] ?? 0 ) + 1; if ( $stored['fails'] >= self::FAILURES_TO_DISABLE ) { $stored['ok'] = false; $stored['checked'] = time(); /* translators: %d: HTTP status code */ $stored['error'] = sprintf( \__( 'Visitors\' browsers got HTTP status %d from it.', '0-day-analytics' ), $status ); } \update_option( self::OPTION, $stored, true ); } /** * Removes the stored config - the endpoint then refuses every request. * * Run on admin requests while the module is off or uses server-side * tracking, and on uninstall. * * @return void * * @since 5.4.0 */ public static function disable(): void { if ( false !== \get_option( self::OPTION, false ) ) { \delete_option( self::OPTION ); } } /** * State for the settings screen and the admin notice. * * @return array{state:string, error:string, checked:int, fails:int, failures_to_disable:int, retest_after:int} * state: off, untested (browsers are verifying it), ok, failed. * * @since 5.4.0 */ public static function status(): array { $stored = \get_option( self::OPTION ); $stored = is_array( $stored ) && array_key_exists( 'fails', $stored ) ? $stored : array(); if ( ! self::is_allowed() ) { $state = 'off'; } elseif ( ! isset( $stored['ok'] ) ) { $state = 'untested'; } else { $state = $stored['ok'] ? 'ok' : 'failed'; } return array( 'state' => $state, 'error' => is_string( $stored['error'] ?? null ) ? $stored['error'] : '', 'checked' => (int) ( $stored['checked'] ?? 0 ), 'fails' => (int) ( $stored['fails'] ?? 0 ), 'failures_to_disable' => self::FAILURES_TO_DISABLE, 'retest_after' => self::RETEST_AFTER, ); } /** * Why the endpoint is off, when is_allowed() says it is. * * @return string * * @since 5.4.0 */ public static function off_reason(): string { if ( defined( 'ADVAN_TRAFFIC_FAST_COLLECT' ) && ! ADVAN_TRAFFIC_FAST_COLLECT ) { return \__( 'ADVAN_TRAFFIC_FAST_COLLECT is set to false in wp-config.php.', '0-day-analytics' ); } if ( '' === self::key() ) { return \__( 'wp-config.php has no unique AUTH_KEY and AUTH_SALT to sign requests with.', '0-day-analytics' ); } return \__( 'The advan_traffic_fast_collect filter returns false.', '0-day-analytics' ); } /** * What the endpoint needs from the full runtime, as it should be stored now. * * Not the endpoint URL: plugins_url() follows the scheme of the request, * and a site that sees both http and https (a proxy, cron over http) * would keep rewriting the config. * * @return array{v:string, dir:string, host:string, proxy:bool} * * @since 5.4.0 */ private static function wanted_config(): array { // Resolved, so different entry points ("/path/./wp-content", symlinked release directories) give the same value - otherwise each would keep rewriting the config. $dir = Traffic_Buffer::get_buffer_dir(); $dir = \wp_normalize_path( is_dir( $dir ) && realpath( $dir ) ? (string) realpath( $dir ) : (string) preg_replace( '#/(?:\./)+#', '/', $dir ) ); return array( 'v' => ADVAN_VERSION, 'dir' => $dir, 'host' => (string) \wp_parse_url( \home_url(), PHP_URL_HOST ), 'origin' => self::origin( \home_url() ), 'proxy' => (bool) Settings::get_option( 'security_trust_proxy_headers' ), ); } /** * URL of the endpoint file. * * @return string * * @since 5.4.0 */ public static function endpoint_url(): string { return \plugins_url( self::FILE, ADVAN_PLUGIN_ABSOLUTE ); } } }