is_whitelisted( $ip ) ) { return; } if ( $this->get_attempts( $ip ) >= (int) GUARDFORGE_Options::get( 'bf_max_attempts' ) ) { // Staging: let it through. The lockout was already recorded and // counted at the moment the threshold was crossed, in // on_login_failed; doing it again here would tally one // suppressed lockout per reload of the login page rather than one // per lockout, and a developer shut out of the copy they are // debugging is the exact failure this mode exists to prevent. if ( GUARDFORGE_Staging::is_active() ) { return; } status_header( 429 ); nocache_headers(); $minutes = (int) GUARDFORGE_Options::get( 'bf_lockout_minutes' ); wp_die( sprintf( /* translators: %d is the lockout window in minutes. */ esc_html__( 'Too many failed attempts. Try again in %d minutes.', 'guardforge' ), (int) $minutes ), esc_html__( 'Access temporarily blocked', 'guardforge' ), [ 'response' => 429 ] ); } } public function on_login_failed( $username ) { if ( ! GUARDFORGE_Options::get( 'brute_force_protection' ) ) { return; } $ip = GUARDFORGE_Ip::client_ip(); if ( $this->is_whitelisted( $ip ) ) { $this->log_attempt( $ip, $username, 'failed' ); return; } $attempts = $this->increment_attempts( $ip ); $this->log_attempt( $ip, $username, 'failed' ); if ( $attempts >= (int) GUARDFORGE_Options::get( 'bf_max_attempts' ) ) { // Staging: record what would have happened and stop here. // Suppressing the ACTION rather than teaching each listener is the // load-bearing choice. 'guardforge/ip_locked' means "this address // is blocked now", and on a copy it is not. Firing it anyway and // asking every present and future listener — Pro's alerts, the // Cloudflare push, the AI buffer, Cascade Forge, third-party code — to // remember the staging check is a design that fails open, and one // forgotten listener is a firewall rule on the LIVE zone written // from a developer's laptop. if ( GUARDFORGE_Staging::is_active() ) { $this->log_attempt( $ip, $username, 'soft_locked' ); GUARDFORGE_Staging::note_suppressed( 'lockout', (string) $ip ); /** * Fires where 'guardforge/ip_locked' would have fired, on a * site running in staging mode. A listener that wants to know * about near-misses can have them under a name that cannot be * mistaken for a real block. * * @param string $ip * @param string $username * @param int $attempts * @param string $reason Why staging mode is on. */ do_action( 'guardforge/ip_would_lock', $ip, $username, $attempts, GUARDFORGE_Staging::reason() ); return; } $this->log_attempt( $ip, $username, 'locked' ); if ( GUARDFORGE_Options::get( 'notify_on_lockout' ) ) { $this->notify_lockout( $ip, $username ); } /** * Fires when an IP crosses the brute-force threshold. * * @param string $ip * @param string $username * @param int $attempts */ do_action( 'guardforge/ip_locked', $ip, $username, $attempts ); } } public function on_login_success( $user_login, $user = null ) { if ( ! GUARDFORGE_Options::get( 'brute_force_protection' ) ) { return; } $ip = GUARDFORGE_Ip::client_ip(); $this->clear_attempts( $ip ); $this->log_attempt( $ip, $user_login, 'success' ); } // ───────────────────────── public API ─────────────────────────── public function get_attempts( $ip ) { return (int) get_transient( self::attempts_key( $ip ) ); } public function clear_attempts( $ip ) { delete_transient( self::attempts_key( $ip ) ); } public function increment_attempts( $ip ) { $key = self::attempts_key( $ip ); $current = $this->get_attempts( $ip ); $attempts = $current + 1; $window = max( 1, (int) GUARDFORGE_Options::get( 'bf_lockout_minutes' ) ) * MINUTE_IN_SECONDS; set_transient( $key, $attempts, $window ); return $attempts; } public static function attempts_key( $ip ) { return self::ATTEMPTS_TRANSIENT_PREFIX . md5( (string) $ip ); } public function is_whitelisted( $ip ) { $raw = GUARDFORGE_Options::get( 'bf_whitelist_ips', '' ); if ( ! is_string( $raw ) || $raw === '' ) { return false; } foreach ( preg_split( '/[\r\n,]+/', $raw ) as $line ) { $line = trim( $line ); if ( $line === '' || $line[0] === '#' ) { continue; } if ( $line === $ip ) { return true; } if ( strpos( $line, '/' ) !== false && GUARDFORGE_Ip::ip_in_cidr( $ip, $line ) ) { return true; } } return false; } public function log_attempt( $ip, $username, $result ) { if ( ! GUARDFORGE_Options::get( 'bf_log_enabled' ) ) { return; } global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- plugin-owned table; $wpdb escapes insert/update/replace/delete values itself, and there is nothing to cache on a write. $wpdb->insert( $wpdb->prefix . 'guardforge_login_log', [ 'event_time' => current_time( 'mysql' ), 'ip' => substr( (string) $ip, 0, 45 ), 'username' => substr( sanitize_text_field( (string) $username ), 0, 200 ), 'result' => substr( sanitize_text_field( (string) $result ), 0, 20 ), 'user_agent' => substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ), 0, 1000 ), ] ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery } public function gc_old_logs() { global $wpdb; $days = max( 1, (int) GUARDFORGE_Options::get( 'bf_log_keep_days' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table ($wpdb->prefix + a literal) that no WordPress API reads; every value is a prepare placeholder or a fragment prepared on its own. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}guardforge_login_log WHERE event_time < DATE_SUB(NOW(), INTERVAL %d DAY)", $days ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $audit_days = max( 1, (int) GUARDFORGE_Options::get( 'audit_log_keep_days' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table ($wpdb->prefix + a literal) that no WordPress API reads; every value is a prepare placeholder or a fragment prepared on its own. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}guardforge_audit_log WHERE event_time < DATE_SUB(NOW(), INTERVAL %d DAY)", $audit_days ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // Bought explanations. They outlive the longer of the two retentions // above rather than getting one of their own, because by then the two // DELETEs above have removed the event the explanation is about, and a // paid answer to a question nobody can still read is just a row. No // new setting and no new cron hook: this rides the one that already // runs. $explain_days = max( $days, $audit_days ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table ($wpdb->prefix + a literal) that no WordPress API reads; every value is a prepare placeholder or a fragment prepared on its own. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}guardforge_event_explain WHERE created_at < DATE_SUB(NOW(), INTERVAL %d DAY)", $explain_days ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching } private function notify_lockout( $ip, $username ) { // Second line. on_login_failed already returns before reaching here // in staging, so this is belt and braces — deliberately, because it is // the check that has to hold if a future caller sends this mail from // somewhere else, and because a staging check placed after the send // suppresses nothing at all. if ( GUARDFORGE_Staging::is_active() ) { GUARDFORGE_Staging::note_suppressed( 'email', 'lockout' ); return; } $to = GUARDFORGE_Options::get( 'notify_email' ); if ( ! $to ) { $to = get_option( 'admin_email' ); } if ( ! is_email( $to ) ) { return; } $subject = sprintf( '[%s] %s', get_bloginfo( 'name' ), __( 'GuardForge — IP locked out', 'guardforge' ) ); $body = sprintf( /* translators: 1: IP, 2: username, 3: timestamp. */ __( "IP %1\$s was locked out after exceeding the failed-login threshold.\n\nUsername: %2\$s\nTime: %3\$s\n", 'guardforge' ), $ip, $username, current_time( 'mysql' ) ); wp_mail( $to, $subject, $body ); } }