PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.3
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
← All changes | includes/class-login-security.php +75 -16 2.11.11 → 3.0.3 View file →
@@ -101,9 +101,9 @@
101 101 // Check lockout before authentication
102 102 add_filter( 'authenticate', array( $this, 'check_lockout' ), 30, 3 );
103 103
104 104 // Track login attempts
105 - add_action( 'wp_login_failed', array( $this, 'handle_failed_login' ) );
105 + add_action( 'wp_login_failed', array( $this, 'handle_failed_login' ), 10, 2 );
106 106 add_action( 'wp_login', array( $this, 'handle_successful_login' ), 10, 2 );
107 107
108 108 // Hide login errors
109 109 if ( ! empty( $this->options['hide_login_errors'] ) ) {
@@ -207,9 +207,9 @@
207 207 return $location;
208 208 }
209 209
210 210 // Check if the redirect is coming from wp-admin area
211 - $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
211 + $request = Vigilante_IP_Utils::request_uri();
212 212
213 213
214 214 // If accessing wp-admin and being redirected to login, show 404 instead
215 215 if ( self::is_wp_admin_request() ) {
@@ -272,13 +272,19 @@
272 272 * home_url(): the admin area can hang from a different path than the
273 273 * site itself (WP_SITEURL vs WP_HOME), so wp-admin matching needs the
274 274 * raw path.
275 275 *
276 + * Raw also means the %XX octets are neither decoded nor deleted. This path
277 + * decides what is the admin area and what is an open endpoint, so it must
278 + * not be a string the client never sent: with sanitize_text_field() a
279 + * /wp-%41admin/page was read as /wp-admin/page, and /wp-admin/admin-ajax.php%20
280 + * as the open endpoint.
281 + *
276 282 * @since 2.9.4
277 283 * @return string
278 284 */
279 285 private static function get_request_uri_path() {
280 - $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
286 + $request = Vigilante_IP_Utils::request_uri();
281 287
282 288 if ( '' === $request ) {
283 289 return '';
284 290 }
@@ -383,9 +389,9 @@
383 389 return;
384 390 }
385 391
386 392 // Get the request URI
387 - $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
393 + $request = Vigilante_IP_Utils::request_uri();
388 394
389 395
390 396 // Check if accessing wp-admin
391 397 if ( ! self::is_wp_admin_request() ) {
@@ -567,9 +573,9 @@
567 573 }
568 574
569 575 // Log the attempt
570 576 if ( $this->activity_log ) {
571 - $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
577 + $request = Vigilante_IP_Utils::request_uri();
572 578 $this->activity_log->log(
573 579 'login',
574 580 'hidden_login_access',
575 581 __( 'Attempt to access hidden wp-login.php', 'vigilante' ),
@@ -606,9 +612,9 @@
606 612 return;
607 613 }
608 614
609 615 if ( $this->activity_log ) {
610 - $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
616 + $request_uri = Vigilante_IP_Utils::request_uri();
611 617 $this->activity_log->log(
612 618 'login',
613 619 'hidden_login_access',
614 620 __( 'Attempt to access hidden wp-login.php', 'vigilante' ),
@@ -622,12 +628,15 @@
622 628
623 629 /**
624 630 * Get the request path without query string
625 631 *
632 + * As the client sent it, percent-encoding included: is_custom_login_request()
633 + * is the one that decodes, because it is the one that compares.
634 + *
626 635 * @return string
627 636 */
628 637 private function get_request_path() {
629 - $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
638 + $request = Vigilante_IP_Utils::request_uri();
630 639
631 640 // Remove query string
632 641 if ( false !== strpos( $request, '?' ) ) {
633 642 $request = strstr( $request, '?', true );
@@ -648,13 +657,23 @@
648 657
649 658 /**
650 659 * Check if this is a request to our custom login URL
651 660 *
661 + * Both sides are percent-decoded before they are compared. The slug is what
662 + * sanitize_title() stored, and in a non-Latin script that is lower-case
663 + * percent-encoded octets (%d0%b2%d1%85%d0%be%d0%b4 for the Cyrillic word
664 + * for "login"), while a browser sends the same address with upper-case hex
665 + * (%D0%B2...) and some tools with raw UTF-8: three spellings of one
666 + * address. The request used to lose its %XX to sanitize_text_field(), so
667 + * the comparison could never succeed: with such a slug the login page was
668 + * hidden and its own address answered 404 (measured on 1 oct 2026 against
669 + * 3.0.1), which locks an administrator out of a site they are signed out of.
670 + *
652 671 * @param string $request The request path.
653 672 * @return bool
654 673 */
655 674 private function is_custom_login_request( $request ) {
656 - return $request === $this->custom_login_slug;
675 + return rawurldecode( $request ) === rawurldecode( $this->custom_login_slug );
657 676 }
658 677
659 678 /**
660 679 * Filter the login URL
@@ -964,11 +983,10 @@
964 983 // This rejection is ours, not a wrong password. wp_authenticate()
965 984 // still fires wp_login_failed for it, and until 2.11.0 that counted
966 985 // the blocked attempt as one more failure, which rewrote the row's
967 986 // status and produced a fresh lockout, with its critical entry and
968 - // its email, on every POST made during the lockout (S8).
969 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
970 -
987 + // its email, on every POST made during the lockout (S8). The error
988 + // code below is what handle_failed_login() reads to leave it alone.
971 989 return new WP_Error(
972 990 'vigilante_lockout',
973 991 sprintf(
974 992 /* translators: %d: Minutes remaining */
@@ -981,15 +999,56 @@
981 999 return $user;
982 1000 }
983 1001
984 1002 /**
1003 + * Error codes of the refusals Vigilant issues itself
1004 + *
1005 + * WordPress treats every WP_Error out of the authenticate chain as a failed
1006 + * login and fires wp_login_failed for it (wp-includes/pluggable.php,
1007 + * wp_authenticate()). These seven are not wrong passwords: the credentials
1008 + * were right and Vigilant stopped the login for a reason of its own, so none
1009 + * of them counts towards the brute force lockout.
1010 + *
1011 + * The rejection identifies itself by the error code it carries. Until
1012 + * 2.11.12 each one instead added a filter that stayed registered for the
1013 + * rest of the request, which meant that one controlled rejection stopped
1014 + * every later failed login of the same request from being counted: measured
1015 + * on 17 Sep 2026, three wrong passwords for a different account, sent in the
1016 + * same request, none of them recorded. A single XML-RPC system.multicall is
1017 + * enough to make that one request.
1018 + *
1019 + * @since 2.11.12
1020 + *
1021 + * @var string[]
1022 + */
1023 + const CONTROLLED_REJECTIONS = array(
1024 + 'vigilante_2fa_required', // Two factor asked for, by app or by email.
1025 + 'vigilante_lockout', // This address is already locked out.
1026 + 'vigilante_force_reset', // An administrator forced a password reset.
1027 + 'vigilante_password_expired', // Password expiry policy, over XML-RPC.
1028 + 'pending_approval', // Registration awaiting approval.
1029 + 'session_limit_exceeded', // Too many sessions already open.
1030 + 'email_not_verified', // Email address not verified yet.
1031 + );
1032 +
1033 + /**
985 1034 * Handle failed login attempt
986 1035 *
987 - * @param string $username Username that failed.
1036 + * @since 2.11.12 Receives the WP_Error, so a refusal of Vigilant's own is told
1037 + * apart from a wrong password by what it is and not by a flag
1038 + * left behind for the rest of the request.
1039 + *
1040 + * @param string $username Username that failed.
1041 + * @param WP_Error|null $error The error WordPress is reporting, if any.
988 1042 */
989 - public function handle_failed_login( $username ) {
990 - // Skip counting if this is a Vigilante-controlled rejection
991 - // (pending approval, session limit, email verification, etc.)
1043 + public function handle_failed_login( $username, $error = null ) {
1044 + // A refusal of ours, not a wrong password.
1045 + if ( $error instanceof WP_Error && array_intersect( $error->get_error_codes(), self::CONTROLLED_REJECTIONS ) ) {
1046 + return;
1047 + }
1048 +
1049 + // Kept for anything outside the plugin that marks its own controlled
1050 + // rejection. Nothing inside Vigilant sets it any more.
992 1051 if ( apply_filters( 'vigilante_skip_failed_login_count', false ) ) {
993 1052 return;
994 1053 }
995 1054
@@ -1569,9 +1628,9 @@
1569 1628 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1570 1629 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
1571 1630 require_once VIGILANTE_INCLUDES_DIR . 'class-activity-log.php';
1572 1631
1573 - $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
1632 + $request = Vigilante_IP_Utils::request_uri();
1574 1633
1575 1634 $activity_log = new Vigilante_Activity_Log( new Vigilante_Settings(), new Vigilante_Database() );
1576 1635 $activity_log->log(
1577 1636 'login',