'Wordfence', 'better-wp-security/better-wp-security.php' => 'Solid Security', 'all-in-one-wp-security-and-firewall/wp-security.php' => 'All-In-One Security', 'limit-login-attempts-reloaded/limit-login-attempts-reloaded.php' => 'Limit Login Attempts Reloaded', 'wp-cerber/wp-cerber.php' => 'WP Cerber', 'sucuri-scanner/sucuri.php' => 'Sucuri Security', ); $active = (array) get_option( 'active_plugins', array() ); return array_values( array_intersect_key( $known, array_fill_keys( $active, true ) ) ); } public static function clientAddress( array $server ): ?string { $peer = self::canonical( self::addressInput( $server, 'REMOTE_ADDR', 64 ) ); if ( null === $peer ) { return null; } if ( ! defined( 'ANTIVIRUS_AI_TRUSTED_PROXIES' ) || ! is_array( ANTIVIRUS_AI_TRUSTED_PROXIES ) ) { return $peer; } $trusted = array(); foreach ( array_slice( ANTIVIRUS_AI_TRUSTED_PROXIES, 0, 64 ) as $address ) { if ( ! is_string( $address ) || null === self::canonical( $address ) ) { return $peer; } $trusted[] = self::canonical( $address ); } if ( ! in_array( $peer, $trusted, true ) ) { return $peer; } $header = self::addressInput( $server, 'HTTP_X_FORWARDED_FOR', 1024 ); if ( strlen( $header ) > 1024 || '' === $header ) { return $peer; } $parts = explode( ',', $header ); if ( count( $parts ) > 10 ) { return $peer; } $chain = array(); foreach ( $parts as $part ) { $address = self::canonical( trim( $part ) ); if ( null === $address ) { return $peer; } $chain[] = $address; } // Trust only the suffix asserted by an explicitly trusted connection peer. for ( $index = count( $chain ) - 1; $index >= 0; $index-- ) { if ( ! in_array( $chain[ $index ], $trusted, true ) ) { return $chain[ $index ]; } } return $peer; } /** Sanitization may validate a spelling but must never manufacture a trusted identity. */ private static function addressInput( array $server, string $key, int $maximum ): string { if ( ! isset( $server[ $key ] ) || ! is_string( $server[ $key ] ) || strlen( $server[ $key ] ) > $maximum ) { return ''; } $value = wp_unslash( $server[ $key ] ); $clean = sanitize_text_field( $value ); return $clean === $value && ! preg_match( '/[\x00-\x1f\x7f]/', $value ) ? $clean : ''; } private static function canonical( string $ip ): ?string { if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return null; } $bytes = inet_pton( $ip ); // Normalize IPv4-mapped IPv6 so the same network cannot gain two buckets. if ( 16 === strlen( $bytes ) && substr( $bytes, 0, 12 ) === str_repeat( "\0", 10 ) . "\xff\xff" ) { return inet_ntop( substr( $bytes, 12 ) ); } return inet_ntop( $bytes ); } private static function network( string $ip ): string { $packed = inet_pton( $ip ); return 16 === strlen( $packed ) ? inet_ntop( substr( $packed, 0, 8 ) . str_repeat( "\0", 8 ) ) . '/64' : $ip . '/32'; } private static function keys( string $username ): array { $keys = array(); $username = trim( substr( $username, 0, 254 ) ); if ( '' !== $username ) { $identity = self::accountIdentity( $username ); // Unknown usernames share their network bucket; random names cannot exhaust account slots. if ( str_starts_with( $identity, 'id:' ) ) { $keys['account'] = self::key( 'account', $identity ); } } $address = self::clientAddress( $_SERVER ); if ( null !== $address ) { $keys['network'] = self::key( 'network', self::network( $address ) ); } return $keys; } private static function accountIdentity( string $username ): string { $username = strtolower( trim( substr( $username, 0, 254 ) ) ); $user = get_user_by( 'login', $username ); if ( ! $user && is_email( $username ) ) { $user = get_user_by( 'email', $username ); } return $user ? 'id:' . (int) $user->ID : 'unknown:' . $username; } private static function key( string $kind, string $value ): string { return 'th_' . substr( hash_hmac( 'sha256', $kind . '|' . $value, wp_salt( 'auth' ) ), 0, 60 ); } public static function authenticate( $user, $username, $password ) { if ( ! is_string( $username ) ) { return $user; } try { $store = new Store(); foreach ( self::keys( $username ) as $kind => $key ) { $bucket = $store->get( $key ); $limit = 'account' === $kind ? 10 : 30; if ( $bucket && (int) $bucket['expires'] > time() && (int) $bucket['attempts'] >= $limit ) { return new \WP_Error( 'antivirus_ai_throttled', __( 'Too many sign-in attempts. Wait 15 minutes and try again. The site owner can use the documented recovery switch.', 'shieldlume-security' ) ); } } } catch ( \Throwable $error ) { self::recordFailure( 'Login throttling storage failed; login remains available.' ); } return $user; } public static function failed( string $username ): void { $store = new Store(); $token = null; try { $token = $store->lock( 'login_throttle', 15 ); if ( ! $token ) { self::recordFailure( 'Login throttle was busy; this failed attempt was not recorded.' ); return; } // Expiry cleanup is bounded. No IP, username, or password is persisted. $offset = (int) get_option( 'antivirus_ai_throttle_cursor', 0 ); $rows = $store->list( 'throttle', null, 100, $offset ); foreach ( $rows as $bucket ) { if ( (int) $bucket['expires'] <= time() ) { $store->delete( $bucket['_id'] ); } } update_option( 'antivirus_ai_throttle_cursor', count( $rows ) < 100 || $offset >= self::CAPACITY ? 0 : $offset + 100, false ); foreach ( self::keys( $username ) as $kind => $key ) { $bucket = $store->get( $key ); $capacity = 'account' === $kind ? self::CAPACITY : self::CAPACITY - self::ACCOUNT_RESERVE; if ( ! $bucket && $store->count( 'throttle' ) >= $capacity ) { self::recordFailure( 'Login throttling key budget reached; existing counters remain active. Some new networks cannot be tracked while capacity is full.' ); continue; } if ( ! $bucket || (int) $bucket['expires'] <= time() ) { $bucket = array( 'kind' => $kind, 'attempts' => 0, 'expires' => time() + self::WINDOW, ); } $bucket['attempts'] = min( 1000, (int) $bucket['attempts'] + 1 ); $store->put( 'throttle', $key, $bucket, 'active' ); } } catch ( \Throwable $error ) { self::recordFailure( 'Login throttling storage failed; login remains available.' ); } finally { if ( $token ) { $store->unlock( 'login_throttle', $token ); } } } public static function succeeded( string $username, $user ): void { try { ( new Store() )->delete( self::key( 'account', self::accountIdentity( $username ) ) ); } catch ( \Throwable $error ) { self::recordFailure( 'Could not reset the login account counter.' ); } } private static function recordFailure( string $message ): void { $previous = get_option( 'antivirus_ai_login_warning', array() ); if ( empty( $previous['at'] ) || time() - (int) $previous['at'] > 3600 ) { update_option( 'antivirus_ai_login_warning', array( 'at' => time(), 'message' => $message, ), false ); } } }