| @@ -862,8 +862,15 @@ | ||
| 862 | 862 | 'warning' |
| 863 | 863 | ); |
| 864 | 864 | } |
| 865 | 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 | + | |
| 866 | 873 | return new WP_Error( |
| 867 | 874 | 'vigilante_lockout', |
| 868 | 875 | sprintf( |
| 869 | 876 | /* translators: %d: Minutes remaining */ |
| @@ -933,8 +940,15 @@ | ||
| 933 | 940 | // Get failed attempts in the last hour |
| 934 | 941 | $failed_count = $this->database->get_failed_attempt_count( $ip, 60 ); |
| 935 | 942 | |
| 936 | 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 | + | |
| 937 | 951 | // Calculate lockout duration with increment |
| 938 | 952 | if ( ! empty( $this->options['lockout_increment'] ) ) { |
| 939 | 953 | $previous_lockouts = $this->get_previous_lockout_count( $ip ); |
| 940 | 954 | $lockout_duration = min( |
| @@ -1116,11 +1130,13 @@ | ||
| 1116 | 1130 | // Login Security |
| 1117 | 1131 | 'vigilante_lockout', |
| 1118 | 1132 | // User Security |
| 1119 | 1133 | 'vigilante_force_reset', |
| 1120 | - 'pending_approval', | |
| 1121 | - 'email_not_verified', | |
| 1122 | - '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. | |
| 1123 | 1139 | // Two-Factor Email |
| 1124 | 1140 | 'no_code', |
| 1125 | 1141 | 'code_expired', |
| 1126 | 1142 | 'code_used', |
| @@ -1178,14 +1194,10 @@ | ||
| 1178 | 1194 | // Note: this fallback won't match on translated sites — the |
| 1179 | 1195 | // code-based check above is the locale-safe path. |
| 1180 | 1196 | $allowed_patterns = array( |
| 1181 | 1197 | 'vigilante_lockout', |
| 1182 | - 'Account pending', | |
| 1183 | - 'pending_approval', | |
| 1184 | - 'email_not_verified', | |
| 1185 | - 'verify your email', | |
| 1186 | - 'session_limit', | |
| 1187 | - '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). | |
| 1188 | 1200 | 'verification code', |
| 1189 | 1201 | 'authenticator app', |
| 1190 | 1202 | 'two-factor', |
| 1191 | 1203 | 'grace period', |
| @@ -1210,15 +1222,15 @@ | ||
| 1210 | 1222 | * @return array |
| 1211 | 1223 | */ |
| 1212 | 1224 | public function remove_shake_errors( $codes ) { |
| 1213 | 1225 | // Keep shake for Vigilante-specific errors that indicate real problems |
| 1214 | - // 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). | |
| 1215 | 1230 | return array( |
| 1216 | 1231 | 'vigilante_lockout', |
| 1217 | 1232 | 'vigilante_force_reset', |
| 1218 | - 'pending_approval', | |
| 1219 | - 'email_not_verified', | |
| 1220 | - 'session_limit_exceeded', | |
| 1221 | 1233 | ); |
| 1222 | 1234 | } |
| 1223 | 1235 | |
| 1224 | 1236 | |
| @@ -1287,9 +1299,9 @@ | ||
| 1287 | 1299 | __( 'Failed attempts', 'vigilante' ) => (string) $attempts, |
| 1288 | 1300 | __( 'Lockout duration', 'vigilante' ) => ceil( $duration / 60 ) . ' ' . __( 'minutes', 'vigilante' ), |
| 1289 | 1301 | __( 'Date/Time', 'vigilante' ) => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ), |
| 1290 | 1302 | ) ); |
| 1291 | - $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' ) ); | |
| 1292 | 1304 | |
| 1293 | 1305 | Vigilante_Email_Template::send( $to, $subject, __( 'Login lockout triggered', 'vigilante' ), $body, true ); |
| 1294 | 1306 | } |
| 1295 | 1307 | |
| @@ -1376,8 +1388,39 @@ | ||
| 1376 | 1388 | if ( ! empty( $whitelist ) && Vigilante_IP_Utils::in_list( Vigilante_IP_Utils::get_client_ip(), $whitelist ) ) { |
| 1377 | 1389 | return; |
| 1378 | 1390 | } |
| 1379 | 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 | + | |
| 1380 | 1423 | self::log_early_hidden_admin_attempt(); |
| 1381 | 1424 | |
| 1382 | 1425 | status_header( 404 ); |
| 1383 | 1426 | nocache_headers(); |
| @@ -1506,43 +1549,8 @@ | ||
| 1506 | 1549 | public function get_locked_out_ips() { |
| 1507 | 1550 | return $this->database->get_locked_out_ips(); |
| 1508 | 1551 | } |
| 1509 | 1552 | |
| 1510 | - /** | |
| 1511 | - * Get login statistics | |
| 1512 | - * | |
| 1513 | - * @param int $days Days to look back. | |
| 1514 | - * @return array | |
| 1515 | - */ | |
| 1516 | - public function get_statistics( $days = 7 ) { | |
| 1517 | - global $wpdb; | |
| 1518 | - | |
| 1519 | - $table = esc_sql( $this->database->get_login_attempts_table() ); | |
| 1520 | - $since = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ); | |
| 1521 | - | |
| 1522 | - // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| 1523 | - $stats = $wpdb->get_row( | |
| 1524 | - $wpdb->prepare( | |
| 1525 | - "SELECT | |
| 1526 | - COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed_attempts, | |
| 1527 | - COUNT(CASE WHEN status = 'lockout' THEN 1 END) as lockouts, | |
| 1528 | - COUNT(DISTINCT ip_address) as unique_ips, | |
| 1529 | - COUNT(DISTINCT username) as unique_usernames | |
| 1530 | - FROM `{$table}` | |
| 1531 | - WHERE last_attempt >= %s", | |
| 1532 | - $since | |
| 1533 | - ), | |
| 1534 | - ARRAY_A | |
| 1535 | - ); | |
| 1536 | - // phpcs:enable | |
| 1537 | - | |
| 1538 | - return $stats ? $stats : array( | |
| 1539 | - 'failed_attempts' => 0, | |
| 1540 | - 'lockouts' => 0, | |
| 1541 | - 'unique_ips' => 0, | |
| 1542 | - 'unique_usernames' => 0, | |
| 1543 | - ); | |
| 1544 | - } | |
| 1545 | 1553 | } |
| 1546 | 1554 | |
| 1547 | 1555 | /** |
| 1548 | 1556 | * Disabled XML-RPC Server class |