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 -98 2.9.62.11.8 View file →
@@ -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
@@ -521,14 +508,8 @@
521 508 wp_safe_redirect( admin_url() );
522 509 exit;
523 510 }
524 511
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 512 // Log the attempt
532 513 if ( $this->activity_log ) {
533 514 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
534 515 $this->activity_log->log(
@@ -567,13 +548,8 @@
567 548 if ( ! in_array( $request, array( 'login', 'wp-login.php' ), true ) ) {
568 549 return;
569 550 }
570 551
571 - // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
572 - if ( $this->is_ip_exempt_from_hiding() ) {
573 - return;
574 - }
575 -
576 552 if ( $this->activity_log ) {
577 553 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
578 554 $this->activity_log->log(
579 555 'login',
@@ -886,8 +862,15 @@
886 862 'warning'
887 863 );
888 864 }
889 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 +
890 873 return new WP_Error(
891 874 'vigilante_lockout',
892 875 sprintf(
893 876 /* translators: %d: Minutes remaining */
@@ -957,8 +940,15 @@
957 940 // Get failed attempts in the last hour
958 941 $failed_count = $this->database->get_failed_attempt_count( $ip, 60 );
959 942
960 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 +
961 951 // Calculate lockout duration with increment
962 952 if ( ! empty( $this->options['lockout_increment'] ) ) {
963 953 $previous_lockouts = $this->get_previous_lockout_count( $ip );
964 954 $lockout_duration = min(
@@ -1140,11 +1130,13 @@
1140 1130 // Login Security
1141 1131 'vigilante_lockout',
1142 1132 // User Security
1143 1133 'vigilante_force_reset',
1144 - 'pending_approval',
1145 - 'email_not_verified',
1146 - '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.
1147 1139 // Two-Factor Email
1148 1140 'no_code',
1149 1141 'code_expired',
1150 1142 'code_used',
@@ -1202,14 +1194,10 @@
1202 1194 // Note: this fallback won't match on translated sites — the
1203 1195 // code-based check above is the locale-safe path.
1204 1196 $allowed_patterns = array(
1205 1197 'vigilante_lockout',
1206 - 'Account pending',
1207 - 'pending_approval',
1208 - 'email_not_verified',
1209 - 'verify your email',
1210 - 'session_limit',
1211 - '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).
1212 1200 'verification code',
1213 1201 'authenticator app',
1214 1202 'two-factor',
1215 1203 'grace period',
@@ -1234,27 +1222,18 @@
1234 1222 * @return array
1235 1223 */
1236 1224 public function remove_shake_errors( $codes ) {
1237 1225 // Keep shake for Vigilante-specific errors that indicate real problems
1238 - // 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).
1239 1230 return array(
1240 1231 'vigilante_lockout',
1241 1232 'vigilante_force_reset',
1242 - 'pending_approval',
1243 - 'email_not_verified',
1244 - 'session_limit_exceeded',
1245 1233 );
1246 1234 }
1247 1235
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 1236
1258 1237 /**
1259 1238 * Disable XML-RPC pingback method
1260 1239 *
@@ -1260,14 +1239,8 @@
1260 1239 *
1261 1240 * @param array $methods XML-RPC methods.
1262 1241 * @return array
1263 1242 */
1264 - public function disable_xmlrpc_pingback( $methods ) {
1265 - unset( $methods['pingback.ping'] );
1266 - unset( $methods['pingback.extensions.getPingbacks'] );
1267 - return $methods;
1268 - }
1269 -
1270 1243 /**
1271 1244 * Notify admin of admin login
1272 1245 *
1273 1246 * @param string $user_login Username.
@@ -1326,9 +1299,9 @@
1326 1299 __( 'Failed attempts', 'vigilante' ) => (string) $attempts,
1327 1300 __( 'Lockout duration', 'vigilante' ) => ceil( $duration / 60 ) . ' ' . __( 'minutes', 'vigilante' ),
1328 1301 __( 'Date/Time', 'vigilante' ) => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
1329 1302 ) );
1330 - $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' ) );
1331 1304
1332 1305 Vigilante_Email_Template::send( $to, $subject, __( 'Login lockout triggered', 'vigilante' ), $body, true );
1333 1306 }
1334 1307
@@ -1378,10 +1351,143 @@
1378 1351 return Vigilante_IP_Utils::in_list( $ip, $whitelist );
1379 1352 }
1380 1353
1381 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 + /**
1382 1488 * Whether the current request comes from an IP that may bypass the
1383 - * hidden-login / hidden-wp-admin masking.
1489 + * hidden wp-admin masking.
1384 1490 *
1385 1491 * Reads the firewall's global IP whitelist (the visible "IP whitelist"
1386 1492 * box) so trusted services such as MainWP or ManageWP, which reach
1387 1493 * wp-admin without a WordPress session cookie, are not turned away with
@@ -1387,8 +1493,19 @@
1387 1493 * wp-admin without a WordPress session cookie, are not turned away with
1388 1494 * a 404. This relaxes only the URL masking, never authentication: an
1389 1495 * exempt IP still has to log in normally.
1390 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 + *
1391 1508 * @return bool
1392 1509 */
1393 1510 private function is_ip_exempt_from_hiding() {
1394 1511 $whitelist = $this->settings->get_option( 'firewall', 'ip_whitelist', array() );
@@ -1432,43 +1549,8 @@
1432 1549 public function get_locked_out_ips() {
1433 1550 return $this->database->get_locked_out_ips();
1434 1551 }
1435 1552
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 1553 }
1472 1554
1473 1555 /**
1474 1556 * Disabled XML-RPC Server class