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 +203 -53 2.11.6 → 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() ) {
@@ -472,14 +478,49 @@
472 478 if ( defined( 'VIGILANTE_CUSTOM_LOGIN' ) && VIGILANTE_CUSTOM_LOGIN ) {
473 479 return;
474 480 }
475 481
476 - // Allow POST requests (form submissions)
482 + /*
483 + * Everything from here down reaches wp-login.php WITHOUT having gone
484 + * through the secret address, so nothing rendered from here may carry
485 + * it. The filters this class registers rewrite the form action and
486 + * every login link to the slug, and wp-login.php prints them on each
487 + * page it serves, so any request let through below handed the hidden
488 + * address to whoever asked for it.
489 + *
490 + * Measured on 12 sep 2026 with the slug configured: a plain anonymous
491 + * GET of ?action=lostpassword or ?action=retrievepassword returned it
492 + * three times, ?password=changed twice and ?checkemail=confirm once.
493 + * The exemptions those requests use (the allowed actions and the
494 + * informational query strings) have been there since hiding the login
495 + * existed, so the address was never actually hidden from anyone who
496 + * asked for a password reset page.
497 + *
498 + * The first attempt at this fix dropped the filters only for POST, and
499 + * only helped the POST with no action: the cross review of 2.11.10
500 + * found the four GETs and the POST to ?action=lostpassword, which still
501 + * leaked through lostpassword_redirect. The rule is now one rule, not a
502 + * list of shapes: came in by the slug, or the address is not emitted.
503 + *
504 + * A visitor with a session is the single exception, and on purpose:
505 + * they already have access, and logging out has to land on the hidden
506 + * address or core's redirect to ?loggedout=true would 404.
507 + */
508 + if ( ! is_user_logged_in() ) {
509 + $this->stop_emitting_custom_login_url();
510 + }
511 +
512 + /*
513 + * A POST is let through so a remote manager such as MainWP or ManageWP
514 + * can authenticate, which is what this exemption has always existed for.
515 + * Authentication is unaffected by the lines above, because a successful
516 + * login redirects to the destination and never renders this form.
517 + */
477 518 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
478 519 if ( 'POST' === $request_method ) {
479 520 return;
480 521 }
481 -
522 +
482 523 // Allow AJAX requests
483 524 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
484 525 return;
485 526 }
@@ -501,8 +542,30 @@
501 542 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
502 543 if ( isset( $_GET['checkemail'] ) || isset( $_GET['password'] ) ) {
503 544 return;
504 545 }
546 +
547 + /*
548 + * A visitor in the middle of a second factor verification. Every redirect
549 + * of that flow goes to wp_login_url(), and with the address filters
550 + * dropped that is the plain wp-login.php, so a wrong code, an expired
551 + * nonce or running out of attempts landed on a 404 with no way back: a
552 + * login started by POST straight at wp-login.php could be begun but never
553 + * finished. Found by the third cross review of 2.11.10, which measured
554 + * 2.11.9 returning the visitor to the login form and trunk returning 404.
555 + *
556 + * The proof asked for is the pending session itself, not the cookie:
557 + * a made-up token finds no transient and gets the 404 like anybody else,
558 + * and the real one is only issued after the right password. Same key as
559 + * the trait (trait-two-factor-session.php:91 and :201).
560 + */
561 + if ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
562 + $pending = get_transient( 'vigilante_2fa_pending_' . sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) );
563 +
564 + if ( is_array( $pending ) && ! empty( $pending['user_id'] ) ) {
565 + return;
566 + }
567 + }
505 568
506 569 // Check if user already logged in - redirect to admin
507 570 if ( is_user_logged_in() ) {
508 571 wp_safe_redirect( admin_url() );
@@ -510,9 +573,9 @@
510 573 }
511 574
512 575 // Log the attempt
513 576 if ( $this->activity_log ) {
514 - $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
577 + $request = Vigilante_IP_Utils::request_uri();
515 578 $this->activity_log->log(
516 579 'login',
517 580 'hidden_login_access',
518 581 __( 'Attempt to access hidden wp-login.php', 'vigilante' ),
@@ -549,9 +612,9 @@
549 612 return;
550 613 }
551 614
552 615 if ( $this->activity_log ) {
553 - $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
616 + $request_uri = Vigilante_IP_Utils::request_uri();
554 617 $this->activity_log->log(
555 618 'login',
556 619 'hidden_login_access',
557 620 __( 'Attempt to access hidden wp-login.php', 'vigilante' ),
@@ -565,12 +628,15 @@
565 628
566 629 /**
567 630 * Get the request path without query string
568 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 + *
569 635 * @return string
570 636 */
571 637 private function get_request_path() {
572 - $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
638 + $request = Vigilante_IP_Utils::request_uri();
573 639
574 640 // Remove query string
575 641 if ( false !== strpos( $request, '?' ) ) {
576 642 $request = strstr( $request, '?', true );
@@ -591,13 +657,23 @@
591 657
592 658 /**
593 659 * Check if this is a request to our custom login URL
594 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 + *
595 671 * @param string $request The request path.
596 672 * @return bool
597 673 */
598 674 private function is_custom_login_request( $request ) {
599 - return $request === $this->custom_login_slug;
675 + return rawurldecode( $request ) === rawurldecode( $this->custom_login_slug );
600 676 }
601 677
602 678 /**
603 679 * Filter the login URL
@@ -664,12 +740,53 @@
664 740 * @param string $register_url The register URL.
665 741 * @return string
666 742 */
667 743 public function filter_register_url( $register_url ) {
744 + /*
745 + * Not from wp-signup.php. On a single site the core answers that file
746 + * with wp_redirect( wp_registration_url() ) and dies (wp-signup.php:39-41),
747 + * so this filter put the secret address in the Location header of a plain
748 + * anonymous request, outside wp-login.php and therefore out of reach of
749 + * block_wp_login_access(), which only runs on login_init. Measured by the
750 + * third cross review of 2.11.10; present since hiding the login existed.
751 + *
752 + * Left unfiltered, that redirect lands on wp-login.php?action=register,
753 + * which the blocker answers with the same 404 as any other direct visit,
754 + * which is what hiding the login is for. The registration link served on
755 + * the login page itself is rendered under the slug, where this filter goes
756 + * on doing its job.
757 + */
758 + if ( $this->request_is_signup() ) {
759 + return $register_url;
760 + }
761 +
668 762 return add_query_arg( 'action', 'register', home_url( $this->custom_login_slug . '/' ) );
669 763 }
670 764
671 765 /**
766 + * Whether this request is being served by wp-signup.php or wp-activate.php
767 + *
768 + * @since 2.11.10
769 + *
770 + * @return bool
771 + */
772 + private function request_is_signup() {
773 + foreach ( array( 'SCRIPT_NAME', 'PHP_SELF', 'SCRIPT_FILENAME' ) as $key ) {
774 + if ( empty( $_SERVER[ $key ] ) ) {
775 + continue;
776 + }
777 +
778 + $file = basename( sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) );
779 +
780 + if ( 'wp-signup.php' === $file || 'wp-activate.php' === $file ) {
781 + return true;
782 + }
783 + }
784 +
785 + return false;
786 + }
787 +
788 + /**
672 789 * Redirect after a successful lost-password request to the custom login URL
673 790 *
674 791 * Without this filter, core sends the user to wp-login.php?checkemail=confirm,
675 792 * which 404s when the custom login URL is enabled (block_wp_login_access only
@@ -866,11 +983,10 @@
866 983 // This rejection is ours, not a wrong password. wp_authenticate()
867 984 // still fires wp_login_failed for it, and until 2.11.0 that counted
868 985 // the blocked attempt as one more failure, which rewrote the row's
869 986 // status and produced a fresh lockout, with its critical entry and
870 - // its email, on every POST made during the lockout (S8).
871 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
872 -
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.
873 989 return new WP_Error(
874 990 'vigilante_lockout',
875 991 sprintf(
876 992 /* translators: %d: Minutes remaining */
@@ -883,15 +999,56 @@
883 999 return $user;
884 1000 }
885 1001
886 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 + /**
887 1034 * Handle failed login attempt
888 1035 *
889 - * @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.
890 1042 */
891 - public function handle_failed_login( $username ) {
892 - // Skip counting if this is a Vigilante-controlled rejection
893 - // (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.
894 1051 if ( apply_filters( 'vigilante_skip_failed_login_count', false ) ) {
895 1052 return;
896 1053 }
897 1054
@@ -1471,9 +1628,9 @@
1471 1628 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1472 1629 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
1473 1630 require_once VIGILANTE_INCLUDES_DIR . 'class-activity-log.php';
1474 1631
1475 - $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
1632 + $request = Vigilante_IP_Utils::request_uri();
1476 1633
1477 1634 $activity_log = new Vigilante_Activity_Log( new Vigilante_Settings(), new Vigilante_Database() );
1478 1635 $activity_log->log(
1479 1636 'login',
@@ -1506,8 +1663,36 @@
1506 1663 * blockers already let every POST through, which is how they authenticate.
1507 1664 *
1508 1665 * @return bool
1509 1666 */
1667 + /**
1668 + * Stop this request from putting the secret login address in any URL
1669 + *
1670 + * The filters that rewrite a WordPress login URL into the custom slug are
1671 + * what makes the feature work, and also what leaks it the moment a page is
1672 + * rendered on a path the visitor was not supposed to reach. Dropping them
1673 + * for the rest of the request keeps whatever renders afterwards free of the
1674 + * slug, while everything else about the request goes on as before.
1675 + *
1676 + * The two redirect filters are here because the first version of this list
1677 + * had only the five URL ones, and a POST to ?action=lostpassword still
1678 + * handed the address over in the hidden redirect_to field that
1679 + * lostpassword_redirect fills. Found by the cross review of 2.11.10. Any
1680 + * filter of this class that can put the slug in front of a visitor belongs
1681 + * in this list; if a new one is added, add it here too.
1682 + *
1683 + * @since 2.11.10
1684 + */
1685 + private function stop_emitting_custom_login_url() {
1686 + remove_filter( 'site_url', array( $this, 'filter_site_url' ), 10 );
1687 + remove_filter( 'login_url', array( $this, 'filter_login_url' ), 10 );
1688 + remove_filter( 'logout_url', array( $this, 'filter_logout_url' ), 10 );
1689 + remove_filter( 'lostpassword_url', array( $this, 'filter_lostpassword_url' ), 10 );
1690 + remove_filter( 'register_url', array( $this, 'filter_register_url' ), 10 );
1691 + remove_filter( 'lostpassword_redirect', array( $this, 'filter_lostpassword_redirect' ), 10 );
1692 + remove_filter( 'logout_redirect', array( $this, 'filter_logout_redirect' ), 10 );
1693 + }
1694 +
1510 1695 private function is_ip_exempt_from_hiding() {
1511 1696 $whitelist = $this->settings->get_option( 'firewall', 'ip_whitelist', array() );
1512 1697
1513 1698 if ( empty( $whitelist ) ) {
@@ -1549,43 +1734,8 @@
1549 1734 public function get_locked_out_ips() {
1550 1735 return $this->database->get_locked_out_ips();
1551 1736 }
1552 1737
1553 - /**
1554 - * Get login statistics
1555 - *
1556 - * @param int $days Days to look back.
1557 - * @return array
1558 - */
1559 - public function get_statistics( $days = 7 ) {
1560 - global $wpdb;
1561 -
1562 - $table = esc_sql( $this->database->get_login_attempts_table() );
1563 - $since = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
1564 -
1565 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1566 - $stats = $wpdb->get_row(
1567 - $wpdb->prepare(
1568 - "SELECT
1569 - COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed_attempts,
1570 - COUNT(CASE WHEN status = 'lockout' THEN 1 END) as lockouts,
1571 - COUNT(DISTINCT ip_address) as unique_ips,
1572 - COUNT(DISTINCT username) as unique_usernames
1573 - FROM `{$table}`
1574 - WHERE last_attempt >= %s",
1575 - $since
1576 - ),
1577 - ARRAY_A
1578 - );
1579 - // phpcs:enable
1580 -
1581 - return $stats ? $stats : array(
1582 - 'failed_attempts' => 0,
1583 - 'lockouts' => 0,
1584 - 'unique_ips' => 0,
1585 - 'unique_usernames' => 0,
1586 - );
1587 - }
1588 1738 }
1589 1739
1590 1740 /**
1591 1741 * Disabled XML-RPC Server class