PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
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 +180 -70 2.9.72.11.8 View file →
@@ -211,11 +211,11 @@
211 211 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
212 212
213 213
214 214 // If accessing wp-admin and being redirected to login, show 404 instead
215 - if ( $this->is_wp_admin_request() ) {
215 + if ( self::is_wp_admin_request() ) {
216 216 // Don't intercept admin-ajax.php or admin-post.php
217 - if ( $this->is_open_admin_endpoint() ) {
217 + if ( self::is_open_admin_endpoint() ) {
218 218 return $location;
219 219 }
220 220
221 221 // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
@@ -275,9 +275,9 @@
275 275 *
276 276 * @since 2.9.4
277 277 * @return string
278 278 */
279 - private function get_request_uri_path() {
279 + private static function get_request_uri_path() {
280 280 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
281 281
282 282 if ( '' === $request ) {
283 283 return '';
@@ -309,14 +309,14 @@
309 309 *
310 310 * @since 2.9.4
311 311 * @return bool
312 312 */
313 - private function is_wp_admin_request() {
313 + private static function is_wp_admin_request() {
314 314 if ( is_admin() ) {
315 315 return true;
316 316 }
317 317
318 - $path = $this->get_request_uri_path();
318 + $path = self::get_request_uri_path();
319 319
320 320 if ( '' === $path ) {
321 321 return false;
322 322 }
@@ -347,10 +347,10 @@
347 347 *
348 348 * @since 2.9.4
349 349 * @return bool
350 350 */
351 - private function is_open_admin_endpoint() {
352 - $path = $this->get_request_uri_path();
351 + private static function is_open_admin_endpoint() {
352 + $path = self::get_request_uri_path();
353 353
354 354 if ( '' === $path ) {
355 355 return false;
356 356 }
@@ -387,14 +387,14 @@
387 387 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
388 388
389 389
390 390 // Check if accessing wp-admin
391 - if ( ! $this->is_wp_admin_request() ) {
391 + if ( ! self::is_wp_admin_request() ) {
392 392 return;
393 393 }
394 394
395 395 // Allow admin-ajax.php and admin-post.php
396 - if ( $this->is_open_admin_endpoint() ) {
396 + if ( self::is_open_admin_endpoint() ) {
397 397 return;
398 398 }
399 399
400 400 // Allow if user is logged in
@@ -508,14 +508,8 @@
508 508 wp_safe_redirect( admin_url() );
509 509 exit;
510 510 }
511 511
512 - // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
513 - if ( $this->is_ip_exempt_from_hiding() ) {
514 - return;
515 - }
516 -
517 -
518 512 // Log the attempt
519 513 if ( $this->activity_log ) {
520 514 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
521 515 $this->activity_log->log(
@@ -554,13 +548,8 @@
554 548 if ( ! in_array( $request, array( 'login', 'wp-login.php' ), true ) ) {
555 549 return;
556 550 }
557 551
558 - // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
559 - if ( $this->is_ip_exempt_from_hiding() ) {
560 - return;
561 - }
562 -
563 552 if ( $this->activity_log ) {
564 553 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
565 554 $this->activity_log->log(
566 555 'login',
@@ -873,8 +862,15 @@
873 862 'warning'
874 863 );
875 864 }
876 865
866 + // This rejection is ours, not a wrong password. wp_authenticate()
867 + // still fires wp_login_failed for it, and until 2.11.0 that counted
868 + // the blocked attempt as one more failure, which rewrote the row's
869 + // 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 +
877 873 return new WP_Error(
878 874 'vigilante_lockout',
879 875 sprintf(
880 876 /* translators: %d: Minutes remaining */
@@ -944,8 +940,15 @@
944 940 // Get failed attempts in the last hour
945 941 $failed_count = $this->database->get_failed_attempt_count( $ip, 60 );
946 942
947 943 if ( $failed_count >= $max_attempts ) {
944 + // Already locked out: every further POST during the lockout used to
945 + // write another critical entry and send another email (S8). The
946 + // lockout itself is what check_lockout() enforces; nothing to add.
947 + if ( $this->database->is_locked_out( $ip ) ) {
948 + return;
949 + }
950 +
948 951 // Calculate lockout duration with increment
949 952 if ( ! empty( $this->options['lockout_increment'] ) ) {
950 953 $previous_lockouts = $this->get_previous_lockout_count( $ip );
951 954 $lockout_duration = min(
@@ -1127,11 +1130,13 @@
1127 1130 // Login Security
1128 1131 'vigilante_lockout',
1129 1132 // User Security
1130 1133 'vigilante_force_reset',
1131 - 'pending_approval',
1132 - 'email_not_verified',
1133 - 'session_limit_exceeded',
1134 + // pending_approval, email_not_verified and session_limit_exceeded
1135 + // are deliberately NOT here since 2.11.0: they are only raised once
1136 + // the password is correct, so letting them through told an
1137 + // unauthenticated visitor which accounts exist (S10). Those users
1138 + // learn their status from the registration and verification emails.
1134 1139 // Two-Factor Email
1135 1140 'no_code',
1136 1141 'code_expired',
1137 1142 'code_used',
@@ -1189,14 +1194,10 @@
1189 1194 // Note: this fallback won't match on translated sites — the
1190 1195 // code-based check above is the locale-safe path.
1191 1196 $allowed_patterns = array(
1192 1197 'vigilante_lockout',
1193 - 'Account pending',
1194 - 'pending_approval',
1195 - 'email_not_verified',
1196 - 'verify your email',
1197 - 'session_limit',
1198 - 'too many active',
1198 + // The pending-approval, unverified-email and session-limit strings
1199 + // were removed in 2.11.0 for the same reason as their codes above (S10).
1199 1200 'verification code',
1200 1201 'authenticator app',
1201 1202 'two-factor',
1202 1203 'grace period',
@@ -1221,15 +1222,15 @@
1221 1222 * @return array
1222 1223 */
1223 1224 public function remove_shake_errors( $codes ) {
1224 1225 // Keep shake for Vigilante-specific errors that indicate real problems
1225 - // Do NOT include 2FA codes - the form transition should be smooth
1226 + // Do NOT include 2FA codes - the form transition should be smooth.
1227 + // The three account-status codes are not here either since 2.11.0: a
1228 + // shake that only plays for existing accounts is the same tell as the
1229 + // message it replaced (S10).
1226 1230 return array(
1227 1231 'vigilante_lockout',
1228 1232 'vigilante_force_reset',
1229 - 'pending_approval',
1230 - 'email_not_verified',
1231 - 'session_limit_exceeded',
1232 1233 );
1233 1234 }
1234 1235
1235 1236
@@ -1298,9 +1299,9 @@
1298 1299 __( 'Failed attempts', 'vigilante' ) => (string) $attempts,
1299 1300 __( 'Lockout duration', 'vigilante' ) => ceil( $duration / 60 ) . ' ' . __( 'minutes', 'vigilante' ),
1300 1301 __( 'Date/Time', 'vigilante' ) => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
1301 1302 ) );
1302 - $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=login' ), __( 'View lockouts', 'vigilante' ) );
1303 + $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=login#vigilante-section-login-status' ), __( 'View lockouts', 'vigilante' ) );
1303 1304
1304 1305 Vigilante_Email_Template::send( $to, $subject, __( 'Login lockout triggered', 'vigilante' ), $body, true );
1305 1306 }
1306 1307
@@ -1350,10 +1351,143 @@
1350 1351 return Vigilante_IP_Utils::in_list( $ip, $whitelist );
1351 1352 }
1352 1353
1353 1354 /**
1355 + * Turn away an anonymous wp-admin request before WordPress finishes booting
1356 + *
1357 + * The modules are built on init priority 1, so a request that was going to
1358 + * be refused had already paid for the whole boot: the theme, every plugin
1359 + * and every init callback. Measured on a real site, a rejected
1360 + * /wp-admin/index.php cost as much as serving a page.
1361 + *
1362 + * Only the case that can be judged with certainty this early is handled
1363 + * here, an anonymous GET with no session cookie at all; everything else
1364 + * falls through to the usual path untouched. The cookie is only checked for
1365 + * presence: resolving the user here would run is_user_logged_in() before
1366 + * other plugins register their determine_current_user filters, which is how
1367 + * token, JWT and SSO logins are wired.
1368 + *
1369 + * @since 2.9.9
1370 + *
1371 + * @param array $options The plugin options, already read by the caller.
1372 + */
1373 + public static function maybe_block_hidden_admin_early( $options ) {
1374 + if ( self::is_open_admin_endpoint() ) {
1375 + return;
1376 + }
1377 +
1378 + if ( '' === sanitize_title( $options['login_security']['custom_login_url'] ) ) {
1379 + return;
1380 + }
1381 +
1382 + if ( self::has_session_cookie() ) {
1383 + return;
1384 + }
1385 +
1386 + $whitelist = isset( $options['firewall']['ip_whitelist'] ) ? (array) $options['firewall']['ip_whitelist'] : array();
1387 +
1388 + if ( ! empty( $whitelist ) && Vigilante_IP_Utils::in_list( Vigilante_IP_Utils::get_client_ip(), $whitelist ) ) {
1389 + return;
1390 + }
1391 +
1392 + /*
1393 + * Last, and only for a request that was about to be turned away: whether
1394 + * anybody is actually there.
1395 + *
1396 + * A remote manager signs its own call with a token and asks for the
1397 + * dashboard before holding any cookie; its connector resolves the user
1398 + * through determine_current_user and only then, on 'init', sets the
1399 + * cookie and redirects. Turning the request away here, three hooks
1400 + * earlier, means the connector never reaches the point where it would
1401 + * have logged itself in, so it reads the 404 as a site that is broken
1402 + * and retries the whole job. Observed in the wild with
1403 + * ModularConnector/3.2.1, whose every request landed here.
1404 + *
1405 + * Which is also why this went unnoticed for two releases: a connector
1406 + * that already holds a cookie by the time it asks for the dashboard
1407 + * leaves at has_session_cookie() above and never reaches this line. How
1408 + * many connectors work that way is not something to guess at here; what
1409 + * is certain is that reports only came from sites where one did not.
1410 + *
1411 + * The criterion is the one block_wp_admin_access() has always applied,
1412 + * brought to the door 2.9.9 put in front of it. It costs nothing on the
1413 + * ordinary request, which left long before reaching this line, and
1414 + * nothing on the database either: with no cookie to validate, the three
1415 + * core determine_current_user callbacks all decline without a query. The
1416 + * rejection below already pays for an INSERT into the activity log, and
1417 + * resolves this very same user one step later to record who was refused.
1418 + */
1419 + if ( get_current_user_id() ) {
1420 + return;
1421 + }
1422 +
1423 + self::log_early_hidden_admin_attempt();
1424 +
1425 + status_header( 404 );
1426 + nocache_headers();
1427 +
1428 + /*
1429 + * Deliberately not translated. This runs on plugins_loaded, where asking
1430 + * for a translation triggers the just in time text domain notice of
1431 + * WordPress 6.7 and returns the English string anyway. The reader is an
1432 + * anonymous request to an address that is supposed to look absent.
1433 + */
1434 + wp_die(
1435 + '<h1>Page not found</h1><p>The page you are looking for does not exist.</p>',
1436 + '404 Not Found',
1437 + array(
1438 + 'response' => 404,
1439 + 'back_link' => false,
1440 + )
1441 + );
1442 + }
1443 +
1444 + /**
1445 + * Whether the request carries a WordPress session cookie, without resolving it
1446 + *
1447 + * @since 2.9.9
1448 + *
1449 + * @return bool
1450 + */
1451 + private static function has_session_cookie() {
1452 + if ( defined( 'LOGGED_IN_COOKIE' ) && isset( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) {
1453 + return true;
1454 + }
1455 +
1456 + foreach ( array_keys( (array) $_COOKIE ) as $name ) {
1457 + if ( 0 === strpos( (string) $name, 'wordpress_logged_in_' ) || 0 === strpos( (string) $name, 'wordpress_sec_' ) ) {
1458 + return true;
1459 + }
1460 + }
1461 +
1462 + return false;
1463 + }
1464 +
1465 + /**
1466 + * Record an early rejection in the activity log
1467 + *
1468 + * @since 2.9.9
1469 + */
1470 + private static function log_early_hidden_admin_attempt() {
1471 + require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1472 + require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
1473 + require_once VIGILANTE_INCLUDES_DIR . 'class-activity-log.php';
1474 +
1475 + $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
1476 +
1477 + $activity_log = new Vigilante_Activity_Log( new Vigilante_Settings(), new Vigilante_Database() );
1478 + $activity_log->log(
1479 + 'login',
1480 + 'hidden_admin_access',
1481 + 'Attempt to access hidden wp-admin',
1482 + array( 'request_uri' => $request ),
1483 + 'warning'
1484 + );
1485 + }
1486 +
1487 + /**
1354 1488 * Whether the current request comes from an IP that may bypass the
1355 - * hidden-login / hidden-wp-admin masking.
1489 + * hidden wp-admin masking.
1356 1490 *
1357 1491 * Reads the firewall's global IP whitelist (the visible "IP whitelist"
1358 1492 * box) so trusted services such as MainWP or ManageWP, which reach
1359 1493 * wp-admin without a WordPress session cookie, are not turned away with
@@ -1359,8 +1493,19 @@
1359 1493 * wp-admin without a WordPress session cookie, are not turned away with
1360 1494 * a 404. This relaxes only the URL masking, never authentication: an
1361 1495 * exempt IP still has to log in normally.
1362 1496 *
1497 + * wp-admin only, and that is the point. Until 2.9.9 the same exemption
1498 + * also applied to the two wp-login.php paths, where it did not serve that
1499 + * purpose and did real harm: block_wp_login_access() handed the real login
1500 + * form to any whitelisted IP with the custom login URL active, and
1501 + * block_login_shortcuts() is precisely what stops core's
1502 + * wp_redirect_admin_locations() from answering /login with a 302 to
1503 + * wp_login_url(), which under a custom login URL is the secret slug. So
1504 + * exempting it did not merely expose the form, it handed the slug over in
1505 + * the Location header. Remote managers never needed either one: both
1506 + * blockers already let every POST through, which is how they authenticate.
1507 + *
1363 1508 * @return bool
1364 1509 */
1365 1510 private function is_ip_exempt_from_hiding() {
1366 1511 $whitelist = $this->settings->get_option( 'firewall', 'ip_whitelist', array() );
@@ -1404,43 +1549,8 @@
1404 1549 public function get_locked_out_ips() {
1405 1550 return $this->database->get_locked_out_ips();
1406 1551 }
1407 1552
1408 - /**
1409 - * Get login statistics
1410 - *
1411 - * @param int $days Days to look back.
1412 - * @return array
1413 - */
1414 - public function get_statistics( $days = 7 ) {
1415 - global $wpdb;
1416 -
1417 - $table = esc_sql( $this->database->get_login_attempts_table() );
1418 - $since = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
1419 -
1420 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1421 - $stats = $wpdb->get_row(
1422 - $wpdb->prepare(
1423 - "SELECT
1424 - COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed_attempts,
1425 - COUNT(CASE WHEN status = 'lockout' THEN 1 END) as lockouts,
1426 - COUNT(DISTINCT ip_address) as unique_ips,
1427 - COUNT(DISTINCT username) as unique_usernames
1428 - FROM `{$table}`
1429 - WHERE last_attempt >= %s",
1430 - $since
1431 - ),
1432 - ARRAY_A
1433 - );
1434 - // phpcs:enable
1435 -
1436 - return $stats ? $stats : array(
1437 - 'failed_attempts' => 0,
1438 - 'lockouts' => 0,
1439 - 'unique_ips' => 0,
1440 - 'unique_usernames' => 0,
1441 - );
1442 - }
1443 1553 }
1444 1554
1445 1555 /**
1446 1556 * Disabled XML-RPC Server class