| @@ -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 |