lock( 'bot-source-window', 10 ); if ( ! $lock ) { throw new \RuntimeException( 'Request controls are changing. Retry shortly.' ); } try { Store::saveOption( 'antivirus_ai_bot_controls', array( 'mode' => $mode, 'disable_pingbacks' => $disablePingbacks, 'updated' => time() ) ); if ( 'off' === $mode ) { delete_option( 'antivirus_ai_bot_buckets' ); } } finally { $store->unlock( 'bot-source-window', $lock ); } if ( 'off' === $mode ) { wp_clear_scheduled_hook( 'antivirus_ai_bot_cleanup' ); } elseif ( ! wp_next_scheduled( 'antivirus_ai_bot_cleanup' ) ) { wp_schedule_event( time() + 120, 'hourly', 'antivirus_ai_bot_cleanup' ); } } public static function cleanup(): void { $store = new Store(); $lock = $store->lock( 'bot-source-window', 10 ); if ( ! $lock ) { return; } try { $buckets = Store::freshOption( 'antivirus_ai_bot_buckets', array() ); if ( ! is_array( $buckets ) ) { $buckets = array(); } foreach ( $buckets as $key => $row ) { if ( ! is_array( $row ) || (int) ( $row['expires'] ?? 0 ) <= time() ) { unset( $buckets[ $key ] ); } } Store::saveOption( 'antivirus_ai_bot_buckets', $buckets ); } finally { $store->unlock( 'bot-source-window', $lock ); } } public static function stop(): void { wp_clear_scheduled_hook( 'antivirus_ai_bot_cleanup' ); delete_option( 'antivirus_ai_bot_buckets' ); } public static function status(): array { $raw = get_option( 'antivirus_ai_bot_controls', array() ); return array( 'mode' => is_array( $raw ) && in_array( $raw['mode'] ?? '', array( 'observe', 'block' ), true ) ? $raw['mode'] : 'off', 'disable_pingbacks' => true === ( $raw['disable_pingbacks'] ?? false ), 'limitation' => 'Optional local request limits: 120 login-page GETs or 60 XML-RPC POSTs per source per minute. Only requests that reach WordPress are covered. Shared IPs can affect legitimate users; observe and test integrations before blocking. Pingback methods can be disabled separately; other XML-RPC methods remain available. HMAC source buckets expire after two minutes and are removed on the next protected request or hourly cleanup; missed WordPress cron can delay removal. Storage is capped at 512 entries and cleared when limits are disabled. No raw IP or browser fingerprint is retained.' ); } public static function xmlrpcMethods( array $methods ): array { if ( ! ( defined( 'ANTIVIRUS_AI_DISABLE_FIREWALL' ) && ANTIVIRUS_AI_DISABLE_FIREWALL ) && true === self::status()['disable_pingbacks'] ) { unset( $methods['pingback.ping'], $methods['pingback.extensions.getPingbacks'] ); } return $methods; } public static function scope( string $uri, string $method ): ?array { $route = Firewall::route( $uri ); if ( 'GET' === $method && preg_match( '~(?:^|/)wp-login\.php$~D', $route ) ) { return array( 'name' => 'login-page', 'limit' => 120 ); } if ( 'POST' === $method && preg_match( '~(?:^|/)xmlrpc\.php$~D', $route ) ) { return array( 'name' => 'xmlrpc', 'limit' => 60 ); } return null; } public static function advance( array $buckets, string $key, int $limit, int $now ): array { foreach ( $buckets as $id => $row ) { if ( ! is_array( $row ) || ! is_int( $row['expires'] ?? null ) || $row['expires'] <= $now || $row['expires'] > $now + 120 ) { unset( $buckets[ $id ] ); } } if ( ! isset( $buckets[ $key ] ) && count( $buckets ) >= 512 ) { return array( 'status' => 'capacity', 'buckets' => $buckets, 'limited' => false ); } $row = $buckets[ $key ] ?? array( 'start' => $now, 'count' => 0, 'expires' => $now + 120 ); if ( ! is_int( $row['start'] ?? null ) || ! is_int( $row['count'] ?? null ) || $row['start'] <= $now - 60 || $row['start'] > $now ) { $row = array( 'start' => $now, 'count' => 0, 'expires' => $now + 120 ); } $row['count'] = min( 1000, $row['count'] + 1 ); $buckets[ $key ] = $row; return array( 'status' => 'assessed', 'buckets' => $buckets, 'limited' => $row['count'] > $limit ); } public static function guard(): void { if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'ANTIVIRUS_AI_DISABLE_FIREWALL' ) && ANTIVIRUS_AI_DISABLE_FIREWALL ) ) { return; } $config = self::status(); if ( 'off' === $config['mode'] ) { return; } $method = Firewall::requestMethod(); $uri = Firewall::requestUri(); $scope = self::scope( $uri, $method ); $address = LoginLimiter::clientAddress( $_SERVER ); if ( ! $scope || null === $address ) { return; } $key = hash_hmac( 'sha256', $scope['name'] . "\0" . $address, wp_salt( 'auth' ) ); $store = new Store(); $lock = $store->lock( 'bot-source-window', 10 ); if ( ! $lock ) { return; } try { // Re-read the current preference under the write lock before retaining a source bucket. $current = Store::freshOption( 'antivirus_ai_bot_controls', array() ); if ( ! in_array( $current['mode'] ?? '', array( 'observe', 'block' ), true ) ) { return; } $config['mode'] = $current['mode']; $buckets = Store::freshOption( 'antivirus_ai_bot_buckets', array() ); $result = self::advance( is_array( $buckets ) ? $buckets : array(), $key, $scope['limit'], time() ); Store::saveOption( 'antivirus_ai_bot_buckets', $result['buckets'] ); } catch ( \Throwable $error ) { return; } finally { $store->unlock( 'bot-source-window', $lock ); } if ( ! $result['limited'] ) { return; } $match = array( 'rule' => 'bot.endpoint-rate', 'parameter' => '@source' ); $excepted = Firewall::excepted( $match, Firewall::route( $uri ), $method, Firewall::configuration()['exceptions'] ); Firewall::record( $match['rule'], $excepted ? 'excepted' : $config['mode'], $method, Firewall::route( $uri ), '@source' ); if ( ! $excepted && 'block' === $config['mode'] ) { nocache_headers(); header( 'Retry-After: 60' ); wp_die( esc_html__( 'Too many requests to this sign-in or XML-RPC endpoint. Wait a minute and try again.', 'shieldlume-security' ), esc_html__( 'Please wait', 'shieldlume-security' ), array( 'response' => 429 ) ); } } }