PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
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 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-login-security.php +353 -105 2.9.42.11.12 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'] ) ) {
@@ -111,21 +111,8 @@
111 111 add_filter( 'login_errors', array( $this, 'hide_login_errors' ) );
112 112 add_filter( 'shake_error_codes', array( $this, 'remove_shake_errors' ) );
113 113 }
114 114
115 - // Disable XML-RPC
116 - if ( ! empty( $this->options['disable_xmlrpc'] ) ) {
117 - add_filter( 'xmlrpc_enabled', '__return_false' );
118 - add_filter( 'wp_xmlrpc_server_class', array( $this, 'disable_xmlrpc_server' ) );
119 - remove_action( 'wp_head', 'rsd_link' );
120 - remove_action( 'wp_head', 'wlwmanifest_link' );
121 - }
122 -
123 - // Disable XML-RPC pingback method specifically
124 - if ( ! empty( $this->options['disable_xmlrpc_pingback'] ) ) {
125 - add_filter( 'xmlrpc_methods', array( $this, 'disable_xmlrpc_pingback' ) );
126 - }
127 -
128 115 // Disable application passwords
129 116 if ( ! empty( $this->options['disable_application_passwords'] ) ) {
130 117 add_filter( 'wp_is_application_passwords_available', '__return_false' );
131 118 }
@@ -224,11 +211,11 @@
224 211 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
225 212
226 213
227 214 // If accessing wp-admin and being redirected to login, show 404 instead
228 - if ( $this->is_wp_admin_request() ) {
215 + if ( self::is_wp_admin_request() ) {
229 216 // Don't intercept admin-ajax.php or admin-post.php
230 - if ( $this->is_open_admin_endpoint() ) {
217 + if ( self::is_open_admin_endpoint() ) {
231 218 return $location;
232 219 }
233 220
234 221 // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
@@ -288,9 +275,9 @@
288 275 *
289 276 * @since 2.9.4
290 277 * @return string
291 278 */
292 - private function get_request_uri_path() {
279 + private static function get_request_uri_path() {
293 280 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
294 281
295 282 if ( '' === $request ) {
296 283 return '';
@@ -322,14 +309,14 @@
322 309 *
323 310 * @since 2.9.4
324 311 * @return bool
325 312 */
326 - private function is_wp_admin_request() {
313 + private static function is_wp_admin_request() {
327 314 if ( is_admin() ) {
328 315 return true;
329 316 }
330 317
331 - $path = $this->get_request_uri_path();
318 + $path = self::get_request_uri_path();
332 319
333 320 if ( '' === $path ) {
334 321 return false;
335 322 }
@@ -360,10 +347,10 @@
360 347 *
361 348 * @since 2.9.4
362 349 * @return bool
363 350 */
364 - private function is_open_admin_endpoint() {
365 - $path = $this->get_request_uri_path();
351 + private static function is_open_admin_endpoint() {
352 + $path = self::get_request_uri_path();
366 353
367 354 if ( '' === $path ) {
368 355 return false;
369 356 }
@@ -400,14 +387,14 @@
400 387 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
401 388
402 389
403 390 // Check if accessing wp-admin
404 - if ( ! $this->is_wp_admin_request() ) {
391 + if ( ! self::is_wp_admin_request() ) {
405 392 return;
406 393 }
407 394
408 395 // Allow admin-ajax.php and admin-post.php
409 - if ( $this->is_open_admin_endpoint() ) {
396 + if ( self::is_open_admin_endpoint() ) {
410 397 return;
411 398 }
412 399
413 400 // Allow if user is logged in
@@ -485,14 +472,49 @@
485 472 if ( defined( 'VIGILANTE_CUSTOM_LOGIN' ) && VIGILANTE_CUSTOM_LOGIN ) {
486 473 return;
487 474 }
488 475
489 - // Allow POST requests (form submissions)
476 + /*
477 + * Everything from here down reaches wp-login.php WITHOUT having gone
478 + * through the secret address, so nothing rendered from here may carry
479 + * it. The filters this class registers rewrite the form action and
480 + * every login link to the slug, and wp-login.php prints them on each
481 + * page it serves, so any request let through below handed the hidden
482 + * address to whoever asked for it.
483 + *
484 + * Measured on 12 sep 2026 with the slug configured: a plain anonymous
485 + * GET of ?action=lostpassword or ?action=retrievepassword returned it
486 + * three times, ?password=changed twice and ?checkemail=confirm once.
487 + * The exemptions those requests use (the allowed actions and the
488 + * informational query strings) have been there since hiding the login
489 + * existed, so the address was never actually hidden from anyone who
490 + * asked for a password reset page.
491 + *
492 + * The first attempt at this fix dropped the filters only for POST, and
493 + * only helped the POST with no action: the cross review of 2.11.10
494 + * found the four GETs and the POST to ?action=lostpassword, which still
495 + * leaked through lostpassword_redirect. The rule is now one rule, not a
496 + * list of shapes: came in by the slug, or the address is not emitted.
497 + *
498 + * A visitor with a session is the single exception, and on purpose:
499 + * they already have access, and logging out has to land on the hidden
500 + * address or core's redirect to ?loggedout=true would 404.
501 + */
502 + if ( ! is_user_logged_in() ) {
503 + $this->stop_emitting_custom_login_url();
504 + }
505 +
506 + /*
507 + * A POST is let through so a remote manager such as MainWP or ManageWP
508 + * can authenticate, which is what this exemption has always existed for.
509 + * Authentication is unaffected by the lines above, because a successful
510 + * login redirects to the destination and never renders this form.
511 + */
490 512 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
491 513 if ( 'POST' === $request_method ) {
492 514 return;
493 515 }
494 -
516 +
495 517 // Allow AJAX requests
496 518 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
497 519 return;
498 520 }
@@ -514,8 +536,30 @@
514 536 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
515 537 if ( isset( $_GET['checkemail'] ) || isset( $_GET['password'] ) ) {
516 538 return;
517 539 }
540 +
541 + /*
542 + * A visitor in the middle of a second factor verification. Every redirect
543 + * of that flow goes to wp_login_url(), and with the address filters
544 + * dropped that is the plain wp-login.php, so a wrong code, an expired
545 + * nonce or running out of attempts landed on a 404 with no way back: a
546 + * login started by POST straight at wp-login.php could be begun but never
547 + * finished. Found by the third cross review of 2.11.10, which measured
548 + * 2.11.9 returning the visitor to the login form and trunk returning 404.
549 + *
550 + * The proof asked for is the pending session itself, not the cookie:
551 + * a made-up token finds no transient and gets the 404 like anybody else,
552 + * and the real one is only issued after the right password. Same key as
553 + * the trait (trait-two-factor-session.php:91 and :201).
554 + */
555 + if ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
556 + $pending = get_transient( 'vigilante_2fa_pending_' . sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) );
557 +
558 + if ( is_array( $pending ) && ! empty( $pending['user_id'] ) ) {
559 + return;
560 + }
561 + }
518 562
519 563 // Check if user already logged in - redirect to admin
520 564 if ( is_user_logged_in() ) {
521 565 wp_safe_redirect( admin_url() );
@@ -521,14 +565,8 @@
521 565 wp_safe_redirect( admin_url() );
522 566 exit;
523 567 }
524 568
525 - // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
526 - if ( $this->is_ip_exempt_from_hiding() ) {
527 - return;
528 - }
529 -
530 -
531 569 // Log the attempt
532 570 if ( $this->activity_log ) {
533 571 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
534 572 $this->activity_log->log(
@@ -567,13 +605,8 @@
567 605 if ( ! in_array( $request, array( 'login', 'wp-login.php' ), true ) ) {
568 606 return;
569 607 }
570 608
571 - // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
572 - if ( $this->is_ip_exempt_from_hiding() ) {
573 - return;
574 - }
575 -
576 609 if ( $this->activity_log ) {
577 610 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
578 611 $this->activity_log->log(
579 612 'login',
@@ -688,12 +721,53 @@
688 721 * @param string $register_url The register URL.
689 722 * @return string
690 723 */
691 724 public function filter_register_url( $register_url ) {
725 + /*
726 + * Not from wp-signup.php. On a single site the core answers that file
727 + * with wp_redirect( wp_registration_url() ) and dies (wp-signup.php:39-41),
728 + * so this filter put the secret address in the Location header of a plain
729 + * anonymous request, outside wp-login.php and therefore out of reach of
730 + * block_wp_login_access(), which only runs on login_init. Measured by the
731 + * third cross review of 2.11.10; present since hiding the login existed.
732 + *
733 + * Left unfiltered, that redirect lands on wp-login.php?action=register,
734 + * which the blocker answers with the same 404 as any other direct visit,
735 + * which is what hiding the login is for. The registration link served on
736 + * the login page itself is rendered under the slug, where this filter goes
737 + * on doing its job.
738 + */
739 + if ( $this->request_is_signup() ) {
740 + return $register_url;
741 + }
742 +
692 743 return add_query_arg( 'action', 'register', home_url( $this->custom_login_slug . '/' ) );
693 744 }
694 745
695 746 /**
747 + * Whether this request is being served by wp-signup.php or wp-activate.php
748 + *
749 + * @since 2.11.10
750 + *
751 + * @return bool
752 + */
753 + private function request_is_signup() {
754 + foreach ( array( 'SCRIPT_NAME', 'PHP_SELF', 'SCRIPT_FILENAME' ) as $key ) {
755 + if ( empty( $_SERVER[ $key ] ) ) {
756 + continue;
757 + }
758 +
759 + $file = basename( sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) );
760 +
761 + if ( 'wp-signup.php' === $file || 'wp-activate.php' === $file ) {
762 + return true;
763 + }
764 + }
765 +
766 + return false;
767 + }
768 +
769 + /**
696 770 * Redirect after a successful lost-password request to the custom login URL
697 771 *
698 772 * Without this filter, core sends the user to wp-login.php?checkemail=confirm,
699 773 * which 404s when the custom login URL is enabled (block_wp_login_access only
@@ -886,8 +960,14 @@
886 960 'warning'
887 961 );
888 962 }
889 963
964 + // This rejection is ours, not a wrong password. wp_authenticate()
965 + // still fires wp_login_failed for it, and until 2.11.0 that counted
966 + // the blocked attempt as one more failure, which rewrote the row's
967 + // status and produced a fresh lockout, with its critical entry and
968 + // its email, on every POST made during the lockout (S8). The error
969 + // code below is what handle_failed_login() reads to leave it alone.
890 970 return new WP_Error(
891 971 'vigilante_lockout',
892 972 sprintf(
893 973 /* translators: %d: Minutes remaining */
@@ -900,15 +980,56 @@
900 980 return $user;
901 981 }
902 982
903 983 /**
984 + * Error codes of the refusals Vigilant issues itself
985 + *
986 + * WordPress treats every WP_Error out of the authenticate chain as a failed
987 + * login and fires wp_login_failed for it (wp-includes/pluggable.php,
988 + * wp_authenticate()). These seven are not wrong passwords: the credentials
989 + * were right and Vigilant stopped the login for a reason of its own, so none
990 + * of them counts towards the brute force lockout.
991 + *
992 + * The rejection identifies itself by the error code it carries. Until
993 + * 2.11.12 each one instead added a filter that stayed registered for the
994 + * rest of the request, which meant that one controlled rejection stopped
995 + * every later failed login of the same request from being counted: measured
996 + * on 17 Sep 2026, three wrong passwords for a different account, sent in the
997 + * same request, none of them recorded. A single XML-RPC system.multicall is
998 + * enough to make that one request.
999 + *
1000 + * @since 2.11.12
1001 + *
1002 + * @var string[]
1003 + */
1004 + const CONTROLLED_REJECTIONS = array(
1005 + 'vigilante_2fa_required', // Two factor asked for, by app or by email.
1006 + 'vigilante_lockout', // This address is already locked out.
1007 + 'vigilante_force_reset', // An administrator forced a password reset.
1008 + 'vigilante_password_expired', // Password expiry policy, over XML-RPC.
1009 + 'pending_approval', // Registration awaiting approval.
1010 + 'session_limit_exceeded', // Too many sessions already open.
1011 + 'email_not_verified', // Email address not verified yet.
1012 + );
1013 +
1014 + /**
904 1015 * Handle failed login attempt
905 1016 *
906 - * @param string $username Username that failed.
1017 + * @since 2.11.12 Receives the WP_Error, so a refusal of Vigilant's own is told
1018 + * apart from a wrong password by what it is and not by a flag
1019 + * left behind for the rest of the request.
1020 + *
1021 + * @param string $username Username that failed.
1022 + * @param WP_Error|null $error The error WordPress is reporting, if any.
907 1023 */
908 - public function handle_failed_login( $username ) {
909 - // Skip counting if this is a Vigilante-controlled rejection
910 - // (pending approval, session limit, email verification, etc.)
1024 + public function handle_failed_login( $username, $error = null ) {
1025 + // A refusal of ours, not a wrong password.
1026 + if ( $error instanceof WP_Error && array_intersect( $error->get_error_codes(), self::CONTROLLED_REJECTIONS ) ) {
1027 + return;
1028 + }
1029 +
1030 + // Kept for anything outside the plugin that marks its own controlled
1031 + // rejection. Nothing inside Vigilant sets it any more.
911 1032 if ( apply_filters( 'vigilante_skip_failed_login_count', false ) ) {
912 1033 return;
913 1034 }
914 1035
@@ -957,8 +1078,15 @@
957 1078 // Get failed attempts in the last hour
958 1079 $failed_count = $this->database->get_failed_attempt_count( $ip, 60 );
959 1080
960 1081 if ( $failed_count >= $max_attempts ) {
1082 + // Already locked out: every further POST during the lockout used to
1083 + // write another critical entry and send another email (S8). The
1084 + // lockout itself is what check_lockout() enforces; nothing to add.
1085 + if ( $this->database->is_locked_out( $ip ) ) {
1086 + return;
1087 + }
1088 +
961 1089 // Calculate lockout duration with increment
962 1090 if ( ! empty( $this->options['lockout_increment'] ) ) {
963 1091 $previous_lockouts = $this->get_previous_lockout_count( $ip );
964 1092 $lockout_duration = min(
@@ -1140,11 +1268,13 @@
1140 1268 // Login Security
1141 1269 'vigilante_lockout',
1142 1270 // User Security
1143 1271 'vigilante_force_reset',
1144 - 'pending_approval',
1145 - 'email_not_verified',
1146 - 'session_limit_exceeded',
1272 + // pending_approval, email_not_verified and session_limit_exceeded
1273 + // are deliberately NOT here since 2.11.0: they are only raised once
1274 + // the password is correct, so letting them through told an
1275 + // unauthenticated visitor which accounts exist (S10). Those users
1276 + // learn their status from the registration and verification emails.
1147 1277 // Two-Factor Email
1148 1278 'no_code',
1149 1279 'code_expired',
1150 1280 'code_used',
@@ -1202,14 +1332,10 @@
1202 1332 // Note: this fallback won't match on translated sites — the
1203 1333 // code-based check above is the locale-safe path.
1204 1334 $allowed_patterns = array(
1205 1335 'vigilante_lockout',
1206 - 'Account pending',
1207 - 'pending_approval',
1208 - 'email_not_verified',
1209 - 'verify your email',
1210 - 'session_limit',
1211 - 'too many active',
1336 + // The pending-approval, unverified-email and session-limit strings
1337 + // were removed in 2.11.0 for the same reason as their codes above (S10).
1212 1338 'verification code',
1213 1339 'authenticator app',
1214 1340 'two-factor',
1215 1341 'grace period',
@@ -1234,27 +1360,18 @@
1234 1360 * @return array
1235 1361 */
1236 1362 public function remove_shake_errors( $codes ) {
1237 1363 // Keep shake for Vigilante-specific errors that indicate real problems
1238 - // Do NOT include 2FA codes - the form transition should be smooth
1364 + // Do NOT include 2FA codes - the form transition should be smooth.
1365 + // The three account-status codes are not here either since 2.11.0: a
1366 + // shake that only plays for existing accounts is the same tell as the
1367 + // message it replaced (S10).
1239 1368 return array(
1240 1369 'vigilante_lockout',
1241 1370 'vigilante_force_reset',
1242 - 'pending_approval',
1243 - 'email_not_verified',
1244 - 'session_limit_exceeded',
1245 1371 );
1246 1372 }
1247 1373
1248 - /**
1249 - * Disable XML-RPC server
1250 - *
1251 - * @param string $class Server class.
1252 - * @return string
1253 - */
1254 - public function disable_xmlrpc_server( $class ) {
1255 - return 'Vigilante_Disabled_XMLRPC_Server';
1256 - }
1257 1374
1258 1375 /**
1259 1376 * Disable XML-RPC pingback method
1260 1377 *
@@ -1260,14 +1377,8 @@
1260 1377 *
1261 1378 * @param array $methods XML-RPC methods.
1262 1379 * @return array
1263 1380 */
1264 - public function disable_xmlrpc_pingback( $methods ) {
1265 - unset( $methods['pingback.ping'] );
1266 - unset( $methods['pingback.extensions.getPingbacks'] );
1267 - return $methods;
1268 - }
1269 -
1270 1381 /**
1271 1382 * Notify admin of admin login
1272 1383 *
1273 1384 * @param string $user_login Username.
@@ -1326,9 +1437,9 @@
1326 1437 __( 'Failed attempts', 'vigilante' ) => (string) $attempts,
1327 1438 __( 'Lockout duration', 'vigilante' ) => ceil( $duration / 60 ) . ' ' . __( 'minutes', 'vigilante' ),
1328 1439 __( 'Date/Time', 'vigilante' ) => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
1329 1440 ) );
1330 - $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=login' ), __( 'View lockouts', 'vigilante' ) );
1441 + $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=login#vigilante-section-login-status' ), __( 'View lockouts', 'vigilante' ) );
1331 1442
1332 1443 Vigilante_Email_Template::send( $to, $subject, __( 'Login lockout triggered', 'vigilante' ), $body, true );
1333 1444 }
1334 1445
@@ -1378,10 +1489,143 @@
1378 1489 return Vigilante_IP_Utils::in_list( $ip, $whitelist );
1379 1490 }
1380 1491
1381 1492 /**
1493 + * Turn away an anonymous wp-admin request before WordPress finishes booting
1494 + *
1495 + * The modules are built on init priority 1, so a request that was going to
1496 + * be refused had already paid for the whole boot: the theme, every plugin
1497 + * and every init callback. Measured on a real site, a rejected
1498 + * /wp-admin/index.php cost as much as serving a page.
1499 + *
1500 + * Only the case that can be judged with certainty this early is handled
1501 + * here, an anonymous GET with no session cookie at all; everything else
1502 + * falls through to the usual path untouched. The cookie is only checked for
1503 + * presence: resolving the user here would run is_user_logged_in() before
1504 + * other plugins register their determine_current_user filters, which is how
1505 + * token, JWT and SSO logins are wired.
1506 + *
1507 + * @since 2.9.9
1508 + *
1509 + * @param array $options The plugin options, already read by the caller.
1510 + */
1511 + public static function maybe_block_hidden_admin_early( $options ) {
1512 + if ( self::is_open_admin_endpoint() ) {
1513 + return;
1514 + }
1515 +
1516 + if ( '' === sanitize_title( $options['login_security']['custom_login_url'] ) ) {
1517 + return;
1518 + }
1519 +
1520 + if ( self::has_session_cookie() ) {
1521 + return;
1522 + }
1523 +
1524 + $whitelist = isset( $options['firewall']['ip_whitelist'] ) ? (array) $options['firewall']['ip_whitelist'] : array();
1525 +
1526 + if ( ! empty( $whitelist ) && Vigilante_IP_Utils::in_list( Vigilante_IP_Utils::get_client_ip(), $whitelist ) ) {
1527 + return;
1528 + }
1529 +
1530 + /*
1531 + * Last, and only for a request that was about to be turned away: whether
1532 + * anybody is actually there.
1533 + *
1534 + * A remote manager signs its own call with a token and asks for the
1535 + * dashboard before holding any cookie; its connector resolves the user
1536 + * through determine_current_user and only then, on 'init', sets the
1537 + * cookie and redirects. Turning the request away here, three hooks
1538 + * earlier, means the connector never reaches the point where it would
1539 + * have logged itself in, so it reads the 404 as a site that is broken
1540 + * and retries the whole job. Observed in the wild with
1541 + * ModularConnector/3.2.1, whose every request landed here.
1542 + *
1543 + * Which is also why this went unnoticed for two releases: a connector
1544 + * that already holds a cookie by the time it asks for the dashboard
1545 + * leaves at has_session_cookie() above and never reaches this line. How
1546 + * many connectors work that way is not something to guess at here; what
1547 + * is certain is that reports only came from sites where one did not.
1548 + *
1549 + * The criterion is the one block_wp_admin_access() has always applied,
1550 + * brought to the door 2.9.9 put in front of it. It costs nothing on the
1551 + * ordinary request, which left long before reaching this line, and
1552 + * nothing on the database either: with no cookie to validate, the three
1553 + * core determine_current_user callbacks all decline without a query. The
1554 + * rejection below already pays for an INSERT into the activity log, and
1555 + * resolves this very same user one step later to record who was refused.
1556 + */
1557 + if ( get_current_user_id() ) {
1558 + return;
1559 + }
1560 +
1561 + self::log_early_hidden_admin_attempt();
1562 +
1563 + status_header( 404 );
1564 + nocache_headers();
1565 +
1566 + /*
1567 + * Deliberately not translated. This runs on plugins_loaded, where asking
1568 + * for a translation triggers the just in time text domain notice of
1569 + * WordPress 6.7 and returns the English string anyway. The reader is an
1570 + * anonymous request to an address that is supposed to look absent.
1571 + */
1572 + wp_die(
1573 + '<h1>Page not found</h1><p>The page you are looking for does not exist.</p>',
1574 + '404 Not Found',
1575 + array(
1576 + 'response' => 404,
1577 + 'back_link' => false,
1578 + )
1579 + );
1580 + }
1581 +
1582 + /**
1583 + * Whether the request carries a WordPress session cookie, without resolving it
1584 + *
1585 + * @since 2.9.9
1586 + *
1587 + * @return bool
1588 + */
1589 + private static function has_session_cookie() {
1590 + if ( defined( 'LOGGED_IN_COOKIE' ) && isset( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) {
1591 + return true;
1592 + }
1593 +
1594 + foreach ( array_keys( (array) $_COOKIE ) as $name ) {
1595 + if ( 0 === strpos( (string) $name, 'wordpress_logged_in_' ) || 0 === strpos( (string) $name, 'wordpress_sec_' ) ) {
1596 + return true;
1597 + }
1598 + }
1599 +
1600 + return false;
1601 + }
1602 +
1603 + /**
1604 + * Record an early rejection in the activity log
1605 + *
1606 + * @since 2.9.9
1607 + */
1608 + private static function log_early_hidden_admin_attempt() {
1609 + require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1610 + require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
1611 + require_once VIGILANTE_INCLUDES_DIR . 'class-activity-log.php';
1612 +
1613 + $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
1614 +
1615 + $activity_log = new Vigilante_Activity_Log( new Vigilante_Settings(), new Vigilante_Database() );
1616 + $activity_log->log(
1617 + 'login',
1618 + 'hidden_admin_access',
1619 + 'Attempt to access hidden wp-admin',
1620 + array( 'request_uri' => $request ),
1621 + 'warning'
1622 + );
1623 + }
1624 +
1625 + /**
1382 1626 * Whether the current request comes from an IP that may bypass the
1383 - * hidden-login / hidden-wp-admin masking.
1627 + * hidden wp-admin masking.
1384 1628 *
1385 1629 * Reads the firewall's global IP whitelist (the visible "IP whitelist"
1386 1630 * box) so trusted services such as MainWP or ManageWP, which reach
1387 1631 * wp-admin without a WordPress session cookie, are not turned away with
@@ -1387,10 +1631,49 @@
1387 1631 * wp-admin without a WordPress session cookie, are not turned away with
1388 1632 * a 404. This relaxes only the URL masking, never authentication: an
1389 1633 * exempt IP still has to log in normally.
1390 1634 *
1635 + * wp-admin only, and that is the point. Until 2.9.9 the same exemption
1636 + * also applied to the two wp-login.php paths, where it did not serve that
1637 + * purpose and did real harm: block_wp_login_access() handed the real login
1638 + * form to any whitelisted IP with the custom login URL active, and
1639 + * block_login_shortcuts() is precisely what stops core's
1640 + * wp_redirect_admin_locations() from answering /login with a 302 to
1641 + * wp_login_url(), which under a custom login URL is the secret slug. So
1642 + * exempting it did not merely expose the form, it handed the slug over in
1643 + * the Location header. Remote managers never needed either one: both
1644 + * blockers already let every POST through, which is how they authenticate.
1645 + *
1391 1646 * @return bool
1392 1647 */
1648 + /**
1649 + * Stop this request from putting the secret login address in any URL
1650 + *
1651 + * The filters that rewrite a WordPress login URL into the custom slug are
1652 + * what makes the feature work, and also what leaks it the moment a page is
1653 + * rendered on a path the visitor was not supposed to reach. Dropping them
1654 + * for the rest of the request keeps whatever renders afterwards free of the
1655 + * slug, while everything else about the request goes on as before.
1656 + *
1657 + * The two redirect filters are here because the first version of this list
1658 + * had only the five URL ones, and a POST to ?action=lostpassword still
1659 + * handed the address over in the hidden redirect_to field that
1660 + * lostpassword_redirect fills. Found by the cross review of 2.11.10. Any
1661 + * filter of this class that can put the slug in front of a visitor belongs
1662 + * in this list; if a new one is added, add it here too.
1663 + *
1664 + * @since 2.11.10
1665 + */
1666 + private function stop_emitting_custom_login_url() {
1667 + remove_filter( 'site_url', array( $this, 'filter_site_url' ), 10 );
1668 + remove_filter( 'login_url', array( $this, 'filter_login_url' ), 10 );
1669 + remove_filter( 'logout_url', array( $this, 'filter_logout_url' ), 10 );
1670 + remove_filter( 'lostpassword_url', array( $this, 'filter_lostpassword_url' ), 10 );
1671 + remove_filter( 'register_url', array( $this, 'filter_register_url' ), 10 );
1672 + remove_filter( 'lostpassword_redirect', array( $this, 'filter_lostpassword_redirect' ), 10 );
1673 + remove_filter( 'logout_redirect', array( $this, 'filter_logout_redirect' ), 10 );
1674 + }
1675 +
1393 1676 private function is_ip_exempt_from_hiding() {
1394 1677 $whitelist = $this->settings->get_option( 'firewall', 'ip_whitelist', array() );
1395 1678
1396 1679 if ( empty( $whitelist ) ) {
@@ -1432,43 +1715,8 @@
1432 1715 public function get_locked_out_ips() {
1433 1716 return $this->database->get_locked_out_ips();
1434 1717 }
1435 1718
1436 - /**
1437 - * Get login statistics
1438 - *
1439 - * @param int $days Days to look back.
1440 - * @return array
1441 - */
1442 - public function get_statistics( $days = 7 ) {
1443 - global $wpdb;
1444 -
1445 - $table = esc_sql( $this->database->get_login_attempts_table() );
1446 - $since = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
1447 -
1448 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1449 - $stats = $wpdb->get_row(
1450 - $wpdb->prepare(
1451 - "SELECT
1452 - COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed_attempts,
1453 - COUNT(CASE WHEN status = 'lockout' THEN 1 END) as lockouts,
1454 - COUNT(DISTINCT ip_address) as unique_ips,
1455 - COUNT(DISTINCT username) as unique_usernames
1456 - FROM `{$table}`
1457 - WHERE last_attempt >= %s",
1458 - $since
1459 - ),
1460 - ARRAY_A
1461 - );
1462 - // phpcs:enable
1463 -
1464 - return $stats ? $stats : array(
1465 - 'failed_attempts' => 0,
1466 - 'lockouts' => 0,
1467 - 'unique_ips' => 0,
1468 - 'unique_usernames' => 0,
1469 - );
1470 - }
1471 1719 }
1472 1720
1473 1721 /**
1474 1722 * Disabled XML-RPC Server class