PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.2
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.2
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 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 All 93 releases
← All changes | includes/class-user-security.php +546 -104 2.10.1 → 3.0.2 View file →
@@ -42,20 +42,57 @@
42 42
43 43 /**
44 44 * Constructor
45 45 *
46 - * @param Vigilante_Settings $settings Settings instance.
47 - * @param Vigilante_Activity_Log $activity_log Activity log instance.
46 + * @param Vigilante_Settings $settings Settings instance.
47 + * @param Vigilante_Activity_Log $activity_log Activity log instance.
48 + * @param bool $enforcement_only Register only what enforces
49 + * state already written to an
50 + * account. See
51 + * init_enforcement_hooks().
48 52 */
49 - public function __construct( $settings, $activity_log ) {
53 + public function __construct( $settings, $activity_log, $enforcement_only = false ) {
50 54 $this->settings = $settings;
51 55 $this->activity_log = $activity_log;
52 56 $this->options = $settings->get_section( 'user_security' );
53 57
58 + if ( $enforcement_only ) {
59 + $this->init_enforcement_hooks();
60 + return;
61 + }
62 +
54 63 $this->init_hooks();
55 64 }
56 65
57 66 /**
67 + * The hooks that enforce state already written to an account
68 + *
69 + * A forced password reset and a registration waiting for approval are not
70 + * settings, they are marks on somebody's account, and the action that wrote
71 + * them already happened: sessions destroyed, emails sent, the activity log
72 + * saying those accounts cannot get in until they reset or are approved.
73 + *
74 + * Until 2.11.10 both were registered inside the module gate, so turning User
75 + * Security off let every one of those accounts back in with their old
76 + * password, silently and with the flags still in place saying the opposite.
77 + * The forced reset is deliberately not destructive on the password (see
78 + * force_password_reset(), which avoids wp_set_password() so the reset link
79 + * keeps working), so this filter was the only thing holding the door.
80 + * Found by the file-by-file review of 2.11.10.
81 + *
82 + * These two are therefore registered whether the module is on or off. Both
83 + * return immediately when the account carries no mark, so the cost on a site
84 + * that never used either feature is one meta read at login.
85 + *
86 + * @since 2.11.10
87 + */
88 + private function init_enforcement_hooks() {
89 + add_filter( 'authenticate', array( $this, 'check_force_reset_on_login' ), 30, 3 );
90 + add_action( 'after_password_reset', array( $this, 'clear_force_reset_meta' ), 10, 1 );
91 + add_filter( 'wp_authenticate_user', array( $this, 'block_pending_user_login' ), 15, 2 );
92 + }
93 +
94 + /**
58 95 * Initialize hooks
59 96 */
60 97 private function init_hooks() {
61 98 // Block insecure usernames
@@ -104,9 +141,10 @@
104 141 // Registration approval
105 142 $registration_approval = $this->options['registration_approval'] ?? array();
106 143 if ( ! empty( $registration_approval['enabled'] ) ) {
107 144 add_action( 'user_register', array( $this, 'set_user_pending_approval' ), 5 );
108 - add_filter( 'wp_authenticate_user', array( $this, 'block_pending_user_login' ), 15, 2 );
145 + // The blocking half is registered by init_enforcement_hooks(), so an
146 + // account already waiting keeps waiting if the feature is turned off.
109 147 add_action( 'admin_notices', array( $this, 'show_pending_users_notice' ) );
110 148 }
111 149
112 150 // Session limits
@@ -131,8 +169,13 @@
131 169 if ( ! empty( $password_expiration['enabled'] ) ) {
132 170 add_action( 'wp_login', array( $this, 'check_password_expiration' ), 10, 2 );
133 171 add_action( 'admin_notices', array( $this, 'show_password_expiration_notice' ) );
134 172 add_action( 'admin_init', array( $this, 'force_password_change_redirect' ) );
173 + // Enforcement beyond wp-admin: REST and the front end, so an expired
174 + // password cannot keep operating outside the redirect (2.11.9).
175 + add_filter( 'rest_authentication_errors', array( $this, 'block_expired_password_rest' ), 20 );
176 + add_action( 'template_redirect', array( $this, 'force_password_change_frontend' ) );
177 + add_filter( 'authenticate', array( $this, 'block_expired_password_xmlrpc' ), 30, 1 );
135 178 add_action( 'profile_update', array( $this, 'update_password_change_date' ), 10, 2 );
136 179 add_action( 'user_register', array( $this, 'set_initial_password_date' ) );
137 180 add_action( 'user_profile_update_errors', array( $this, 'check_password_history' ), 10, 3 );
138 181
@@ -160,11 +203,11 @@
160 203 add_filter( 'registration_redirect', array( $this, 'custom_registration_redirect' ) );
161 204 add_action( 'login_message', array( $this, 'show_registration_pending_message' ) );
162 205 }
163 206
164 - // Force password reset login message (always active, independent of settings)
165 - add_filter( 'authenticate', array( $this, 'check_force_reset_on_login' ), 30, 3 );
166 - add_action( 'after_password_reset', array( $this, 'clear_force_reset_meta' ), 10, 1 );
207 + // What enforces marks already written to an account, which stays
208 + // registered even with the module off. See init_enforcement_hooks().
209 + $this->init_enforcement_hooks();
167 210 }
168 211
169 212 /**
170 213 * Validate username on profile update
@@ -241,9 +284,11 @@
241 284 * @return bool
242 285 */
243 286 private function is_insecure_username( $username ) {
244 287 $username = strtolower( trim( $username ) );
245 - $insecure_usernames = $this->options['insecure_usernames'] ?? array();
288 + // Vigilante_Settings::list_or_shipped(): an empty list of a setting with
289 + // no field on screen is a leftover, not a choice. See its docblock.
290 + $insecure_usernames = $this->get_insecure_usernames();
246 291
247 292 return in_array( $username, array_map( 'strtolower', $insecure_usernames ), true );
248 293 }
249 294
@@ -945,9 +990,14 @@
945 990 *
946 991 * @return array
947 992 */
948 993 public function get_insecure_usernames() {
949 - return $this->options['insecure_usernames'] ?? array();
994 + // The single reader of this list, so what blocks and what the screen
995 + // reports can never disagree.
996 + return Vigilante_Settings::list_or_shipped(
997 + $this->options['insecure_usernames'] ?? array(),
998 + array( 'user_security', 'insecure_usernames' )
999 + );
950 1000 }
951 1001
952 1002 /**
953 1003 * Check for existing insecure admin users
@@ -1094,13 +1144,23 @@
1094 1144 public function force_password_reset_bulk( $user_ids, $reset_by_user_id = 0 ) {
1095 1145 $results = array(
1096 1146 'success' => 0,
1097 1147 'failed' => 0,
1148 + 'skipped' => 0,
1098 1149 'emails_sent' => 0,
1099 1150 'total' => count( $user_ids ),
1100 1151 );
1101 1152
1102 1153 foreach ( $user_ids as $user_id ) {
1154 + // The caller only proved it holds manage_options, which on a network
1155 + // every subsite administrator has. Resetting somebody else's password
1156 + // locks them out, so each target is checked one by one. Skipped users
1157 + // are counted apart from real failures.
1158 + if ( ! current_user_can( 'edit_user', $user_id ) ) {
1159 + $results['skipped']++;
1160 + continue;
1161 + }
1162 +
1103 1163 $result = $this->force_password_reset( $user_id, $reset_by_user_id );
1104 1164
1105 1165 if ( $result['success'] ) {
1106 1166 $results['success']++;
@@ -1196,22 +1256,27 @@
1196 1256 * @param string $password Password.
1197 1257 * @return WP_User|WP_Error|null
1198 1258 */
1199 1259 public function check_force_reset_on_login( $user, $username, $password ) {
1200 - // Resolve the target user. The flag must be evaluated whether the
1201 - // credentials matched (WP_User) or not (WP_Error).
1202 - if ( $user instanceof WP_User ) {
1203 - $login_user = $user;
1204 - } else {
1205 - $login_user = get_user_by( 'login', $username );
1206 - if ( ! $login_user ) {
1207 - $login_user = get_user_by( 'email', $username );
1208 - }
1260 + /*
1261 + * Only a login that would otherwise have succeeded is turned into this
1262 + * rejection. Wrong credentials are left exactly as WordPress reported
1263 + * them, and they are counted like any other failed login.
1264 + *
1265 + * Until 2.11.12 this ran for a WP_Error too, resolving the account from
1266 + * the username, and replaced an incorrect_password with the rejection
1267 + * below. A rejection of Vigilant's own is not counted towards the brute
1268 + * force lockout, so any account with a pending forced reset could be
1269 + * guessed at without limit: measured on 17 Sep 2026 against 2.11.11 and
1270 + * against the first build of 2.11.12, six wrong passwords in a row, none
1271 + * of them counted and no lockout at the end. The message this function
1272 + * exists to show belongs to whoever typed the right password.
1273 + */
1274 + if ( ! ( $user instanceof WP_User ) ) {
1275 + return $user;
1209 1276 }
1210 1277
1211 - if ( ! $login_user ) {
1212 - return $user;
1213 - }
1278 + $login_user = $user;
1214 1279
1215 1280 // Check if this user has a pending forced reset.
1216 1281 $force_reset = get_user_meta( $login_user->ID, 'vigilante_force_reset_pending', true );
1217 1282 if ( ! $force_reset ) {
@@ -1217,17 +1282,11 @@
1217 1282 if ( ! $force_reset ) {
1218 1283 return $user;
1219 1284 }
1220 1285
1221 - // If credentials were wrong with an error other than incorrect_password
1222 - // (e.g. a Vigilant lockout, pending approval), don't shadow it.
1223 - if ( is_wp_error( $user ) && ! in_array( 'incorrect_password', $user->get_error_codes(), true ) ) {
1224 - return $user;
1225 - }
1286 + // Not counted towards the brute force lockout: the rejection is
1287 + // recognised by its error code (Vigilante_Login_Security::CONTROLLED_REJECTIONS).
1226 1288
1227 - // Skip brute force counter for this controlled rejection.
1228 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1229 -
1230 1289 // Surface the controlled rejection in the activity log so the admin
1231 1290 // can tell apart "user fails login because they typed wrong password"
1232 1291 // from "user fails login because we are forcing a reset".
1233 1292 if ( $this->activity_log ) {
@@ -1289,9 +1348,14 @@
1289 1348 return;
1290 1349 }
1291 1350
1292 1351 $settings = $this->options['registration_approval'] ?? array();
1293 - $affected_roles = $settings['affected_roles'] ?? array( 'subscriber' );
1352 + // Vigilante_Settings::list_or_shipped(): an empty list of a setting with
1353 + // no field on screen is a leftover, not a choice. See its docblock.
1354 + $affected_roles = Vigilante_Settings::list_or_shipped(
1355 + $settings['affected_roles'] ?? array(),
1356 + array( 'user_security', 'registration_approval', 'affected_roles' )
1357 + );
1294 1358
1295 1359 // Check if user role requires approval
1296 1360 $user_roles = $user->roles;
1297 1361 $needs_approval = array_intersect( $user_roles, $affected_roles );
@@ -1299,11 +1363,11 @@
1299 1363 if ( empty( $needs_approval ) ) {
1300 1364 return;
1301 1365 }
1302 1366
1303 - // Set pending status
1304 - update_user_meta( $user_id, 'vigilante_pending_approval', true );
1305 - update_user_meta( $user_id, 'vigilante_pending_since', time() );
1367 + // Set pending status, on this site only (see site_user_meta_key()).
1368 + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true );
1369 + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ), time() );
1306 1370
1307 1371 // Log
1308 1372 if ( $this->activity_log ) {
1309 1373 $this->activity_log->log(
@@ -1336,14 +1400,9 @@
1336 1400 if ( is_wp_error( $user ) ) {
1337 1401 return $user;
1338 1402 }
1339 1403
1340 - $is_pending = get_user_meta( $user->ID, 'vigilante_pending_approval', true );
1341 -
1342 - if ( $is_pending ) {
1343 - // Mark this as a controlled rejection (not a brute force attempt)
1344 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1345 -
1404 + if ( self::is_pending_anywhere( $user->ID ) ) {
1346 1405 return new WP_Error(
1347 1406 'pending_approval',
1348 1407 __( '<strong>Account pending:</strong> Your account is awaiting administrator approval. You will receive an email once approved.', 'vigilante' )
1349 1408 );
@@ -1383,9 +1442,9 @@
1383 1442 $count,
1384 1443 'vigilante'
1385 1444 ) ),
1386 1445 absint( $count ),
1387 - '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=users' ) ) . '">' . esc_html__( 'Review in Vigilant', 'vigilante' ) . '</a>'
1446 + '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=users#vigilante-section-users-pending' ) ) . '">' . esc_html__( 'Review in Vigilant', 'vigilante' ) . '</a>'
1388 1447 );
1389 1448 ?>
1390 1449 </p>
1391 1450 </div>
@@ -1392,16 +1451,131 @@
1392 1451 <?php
1393 1452 }
1394 1453
1395 1454 /**
1455 + * A user meta key that belongs to one site, even on a network
1456 + *
1457 + * Registration approval is a per-site setting, but user meta is network
1458 + * wide, so a single global key made the pending queue shared: an
1459 + * administrator of one site saw, approved and rejected accounts waiting on
1460 + * another, and clearing the flag cleared it for the whole network. Reported
1461 + * by the wp.org automated review of 2.11.9.
1462 + *
1463 + * On a network the key carries the blog prefix, the way core does with
1464 + * capabilities (wp_2_capabilities), so each site keeps its own queue. On a
1465 + * single site the key is returned unchanged, so nothing has to be migrated
1466 + * there and the stored data of every existing install keeps working.
1467 + *
1468 + * @since 2.11.10
1469 + *
1470 + * Note the default is null and not 0: wpdb::get_blog_prefix() reads null as
1471 + * "the current blog", and 0 as the main site, so passing 0 here gave every
1472 + * subsite the key of the main site and kept the queue shared. Caught by
1473 + * matriz-red-repaso-21110.sh before this shipped.
1474 + *
1475 + * @param string $key Base meta key.
1476 + * @param int|null $blog_id Blog to build it for. Current blog when null.
1477 + * @return string
1478 + */
1479 + public static function site_user_meta_key( $key, $blog_id = null ) {
1480 + global $wpdb;
1481 +
1482 + if ( ! is_multisite() ) {
1483 + return $key;
1484 + }
1485 +
1486 + return $wpdb->get_blog_prefix( $blog_id ) . $key;
1487 + }
1488 +
1489 + /**
1490 + * Whether this account is waiting for approval on ANY site of the network
1491 + *
1492 + * The queue is per site and stays per site, because approving somebody is a
1493 + * decision of the site they signed up to. Blocking them is a different
1494 + * question with a different answer, and giving it the same one was a hole:
1495 + * the session cookie WordPress issues is valid on every host of the network
1496 + * (COOKIE_DOMAIN and COOKIEPATH, wp-includes/ms-default-constants.php:58-59
1497 + * and :84-88), so an account held back on demo1 logged in through the main
1498 + * site, where it carried no flag, and walked straight back into demo1 with
1499 + * that cookie. Reproduced over HTTP by the second cross review of 2.11.10.
1500 + * It is the same reasoning that two_factor_required_for() already applies:
1501 + * network-wide cookie, network-wide enforcement.
1502 + *
1503 + * Read from the account's own meta in one pass rather than by asking site by
1504 + * site, so the cost does not grow with the network. The legacy key with no
1505 + * prefix is included because the migration that moves it runs on the first
1506 + * admin page load and until then a waiting account has to keep being
1507 + * blocked; reading both fails closed.
1508 + *
1509 + * @since 2.11.10
1510 + *
1511 + * @param int $user_id User ID.
1512 + * @return bool
1513 + */
1514 + public static function is_pending_anywhere( $user_id ) {
1515 + global $wpdb;
1516 +
1517 + if ( get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) {
1518 + return true;
1519 + }
1520 +
1521 + if ( ! is_multisite() ) {
1522 + return false;
1523 + }
1524 +
1525 + $all = get_user_meta( $user_id );
1526 +
1527 + if ( ! is_array( $all ) ) {
1528 + return false;
1529 + }
1530 +
1531 + $pattern = '/^' . preg_quote( $wpdb->base_prefix, '/' ) . '(\d+_)?vigilante_pending_approval$/';
1532 +
1533 + foreach ( $all as $key => $values ) {
1534 + if ( ! preg_match( $pattern, $key, $m ) ) {
1535 + continue;
1536 + }
1537 +
1538 + /*
1539 + * A mark left behind by a site that no longer exists asks nobody for
1540 + * anything: deleting a subsite does not touch this plugin's user meta,
1541 + * so the account stayed blocked on the whole network with no queue
1542 + * anywhere to clear it from, in a plugin whose users have no WP-CLI.
1543 + * Found by the third cross review of 2.11.10. get_site() is cached, so
1544 + * this costs nothing in the usual case of no leftovers.
1545 + */
1546 + if ( ! empty( $m[1] ) && ! get_site( (int) rtrim( $m[1], '_' ) ) ) {
1547 + continue;
1548 + }
1549 +
1550 + foreach ( (array) $values as $value ) {
1551 + if ( ! empty( $value ) ) {
1552 + return true;
1553 + }
1554 + }
1555 + }
1556 +
1557 + return false;
1558 + }
1559 +
1560 + /**
1396 1561 * Get pending users
1397 1562 *
1563 + * The meta key is what scopes this list to the current site, so on a network
1564 + * the query deliberately does not add the site's own membership filter on
1565 + * top. Core's WP_User_Query turns the default into "{$prefix}capabilities
1566 + * EXISTS" (wp-includes/class-wp-user-query.php:598-604), and an account that
1567 + * is waiting for approval can perfectly well have no role yet: it then held
1568 + * this site's flag, was blocked from logging in, and appeared in no queue at
1569 + * all, so nobody could ever approve or reject it. Found by the second cross
1570 + * review of 2.11.10.
1571 + *
1398 1572 * @return array Array of pending user objects.
1399 1573 */
1400 1574 public function get_pending_users() {
1401 1575 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
1402 1576 $args = array(
1403 - 'meta_key' => 'vigilante_pending_approval',
1577 + 'meta_key' => self::site_user_meta_key( 'vigilante_pending_approval' ),
1404 1578 'meta_value' => '1',
1405 1579 'orderby' => 'registered',
1406 1580 'order' => 'DESC',
1407 1581 );
@@ -1406,8 +1580,12 @@
1406 1580 'order' => 'DESC',
1407 1581 );
1408 1582 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1409 1583
1584 + if ( is_multisite() ) {
1585 + $args['blog_id'] = 0;
1586 + }
1587 +
1410 1588 return get_users( $args );
1411 1589 }
1412 1590
1413 1591 /**
@@ -1417,17 +1595,28 @@
1417 1595 * @param int $approved_by Admin user ID who approved.
1418 1596 * @return bool
1419 1597 */
1420 1598 public function approve_user( $user_id, $approved_by = 0 ) {
1599 + // Same reasoning as reject_user(): approving an account that never asked
1600 + // for approval is a no-op that reports success and writes misleading meta.
1601 + // Only this site's flag counts, so approving never clears the queue of
1602 + // another site of the network (see site_user_meta_key()).
1603 + if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) {
1604 + return false;
1605 + }
1606 +
1421 1607 $user = get_userdata( $user_id );
1422 1608 if ( ! $user ) {
1423 1609 return false;
1424 1610 }
1425 1611
1426 - delete_user_meta( $user_id, 'vigilante_pending_approval' );
1427 - delete_user_meta( $user_id, 'vigilante_pending_since' );
1428 - update_user_meta( $user_id, 'vigilante_approved_by', $approved_by );
1429 - update_user_meta( $user_id, 'vigilante_approved_date', time() );
1612 + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) );
1613 + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) );
1614 + // Por sitio como las dos de arriba: quien aprueba y cuando es un hecho de
1615 + // la cola de ESTE sitio, y dejarlas globales hacia que una aprobacion
1616 + // pisara el registro de otro (cierra B4 de la revision cruzada).
1617 + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_by' ), $approved_by );
1618 + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_date' ), time() );
1430 1619
1431 1620 // Log
1432 1621 if ( $this->activity_log ) {
1433 1622 $admin = $approved_by ? get_userdata( $approved_by ) : null;
@@ -1464,8 +1653,16 @@
1464 1653 if ( ! $user ) {
1465 1654 return false;
1466 1655 }
1467 1656
1657 + // Only an account actually waiting for approval may be rejected. Without
1658 + // this the handler deletes any user id it is given, and wp_delete_user()
1659 + // with no reassignment takes their posts with them, skipping the dialog
1660 + // core always shows. Deleting a member is the Users screen's job.
1661 + if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) {
1662 + return false;
1663 + }
1664 +
1468 1665 // Log before deletion
1469 1666 if ( $this->activity_log ) {
1470 1667 $admin = $rejected_by ? get_userdata( $rejected_by ) : null;
1471 1668 $this->activity_log->log(
@@ -1489,8 +1686,21 @@
1489 1686
1490 1687 // Send rejection email before deleting
1491 1688 $this->send_rejection_email( $user, $reason );
1492 1689
1690 + /*
1691 + * The mark goes first, because on a network the account may well survive
1692 + * the deletion: wp_delete_user() only calls remove_user_from_blog() there
1693 + * (wp-admin/includes/user.php:440-442), which clears the role and nothing
1694 + * of this plugin's own meta. Leaving it behind made Reject a loop with no
1695 + * way out: the account stayed blocked on every site of the network, the
1696 + * row never left the queue (which since 2.11.10 no longer hides accounts
1697 + * without a role), and pressing Reject again sent the rejection email once
1698 + * more and reported success. Found by the third cross review of 2.11.10.
1699 + */
1700 + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) );
1701 + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) );
1702 +
1493 1703 // Delete user
1494 1704 require_once ABSPATH . 'wp-admin/includes/user.php';
1495 1705 return wp_delete_user( $user_id );
1496 1706 }
@@ -1509,9 +1719,9 @@
1509 1719 __( '[%s] New user registration pending approval', 'vigilante' ),
1510 1720 $site_name
1511 1721 );
1512 1722
1513 - $approve_url = admin_url( 'admin.php?page=vigilante&tab=users' );
1723 + $approve_url = admin_url( 'admin.php?page=vigilante&tab=users#vigilante-section-users-pending' );
1514 1724
1515 1725 $body = Vigilante_Email_Template::p( __( 'A new user has registered and is awaiting your approval.', 'vigilante' ) );
1516 1726 $body .= Vigilante_Email_Template::data_table( array(
1517 1727 __( 'Username', 'vigilante' ) => $user->user_login,
@@ -1872,8 +2082,31 @@
1872 2082 return max( 0, $count );
1873 2083 }
1874 2084
1875 2085 /**
2086 + * Whether the session store this limit would act on belongs to a whole network
2087 + *
2088 + * WP_Session_Tokens keeps session_tokens in the usermeta table, which is
2089 + * network wide, while this limit is configured per site. So on a network a
2090 + * site administrator setting a low limit would count, and with close_oldest
2091 + * close, the sessions the same user opened on other sites, including an
2092 + * administrator session elsewhere; and block_new would refuse a login over
2093 + * sessions that have nothing to do with this site. Reported by the wp.org
2094 + * automated review of 2.11.9, on the close_oldest half.
2095 + *
2096 + * Until the network-wide policy of 3.1.0, the limit simply does not apply on
2097 + * a network, and the settings screen says so. On a single site nothing
2098 + * changes: there the session store and the setting cover the same thing.
2099 + *
2100 + * @since 2.11.10
2101 + *
2102 + * @return bool
2103 + */
2104 + public static function session_limit_is_network_wide() {
2105 + return is_multisite();
2106 + }
2107 +
2108 + /**
1876 2109 * Check session limit before login completes (for block_new behavior)
1877 2110 *
1878 2111 * @param WP_User $user User object.
1879 2112 * @param string $password Password.
@@ -1883,8 +2116,12 @@
1883 2116 if ( is_wp_error( $user ) ) {
1884 2117 return $user;
1885 2118 }
1886 2119
2120 + if ( self::session_limit_is_network_wide() ) {
2121 + return $user;
2122 + }
2123 +
1887 2124 $settings = $this->options['session_limits'] ?? array();
1888 2125 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
1889 2126 $exclude_admins = ! empty( $settings['exclude_admins'] );
1890 2127
@@ -1914,11 +2151,8 @@
1914 2151 'warning'
1915 2152 );
1916 2153 }
1917 2154
1918 - // Mark this as a controlled rejection (not a brute force attempt)
1919 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1920 -
1921 2155 return new WP_Error(
1922 2156 'session_limit_exceeded',
1923 2157 sprintf(
1924 2158 /* translators: %d: Maximum sessions allowed */
@@ -1937,8 +2171,12 @@
1937 2171 * @param string $user_login Username.
1938 2172 * @param WP_User $user User object.
1939 2173 */
1940 2174 public function enforce_session_limit( $user_login, $user ) {
2175 + if ( self::session_limit_is_network_wide() ) {
2176 + return;
2177 + }
2178 +
1941 2179 $settings = $this->options['session_limits'] ?? array();
1942 2180 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
1943 2181 $behavior = $settings['behavior'] ?? 'block_new';
1944 2182 $exclude_admins = ! empty( $settings['exclude_admins'] );
@@ -1957,27 +2195,69 @@
1957 2195 return;
1958 2196 }
1959 2197
1960 2198 if ( 'close_oldest' === $behavior ) {
1961 - // Sort by login time and destroy oldest
1962 - uasort( $all_sessions, function( $a, $b ) {
1963 - return ( $a['login'] ?? 0 ) - ( $b['login'] ?? 0 );
2199 + /*
2200 + * Remove the oldest sessions by editing the session store directly.
2201 + *
2202 + * WP_Session_Tokens::get_all() returns array_values( get_sessions() ),
2203 + * so its keys are 0, 1, 2, not tokens, and destroy() expects a raw
2204 + * token, which is not stored anywhere and cannot be recovered for a
2205 + * session other than the current one. Until 2.11.9 the loop passed
2206 + * those numeric keys to destroy(), which hashed them, matched nothing
2207 + * and closed no session while still counting and logging success, so
2208 + * the cap did nothing under close_oldest. Reported by the wp.org
2209 + * automated review of 2.11.8.
2210 + *
2211 + * The store keeps the sessions as the user meta 'session_tokens',
2212 + * keyed by the verifier hash( 'sha256', token ), which is the value
2213 + * is_current_session() already compares against. So the oldest are
2214 + * removed from that map, keeping the current session whatever its age.
2215 + * On a network the meta is global (one finding of the multisite audit,
2216 + * to be reworked in 3.1.0); here the fix is only to make the removal
2217 + * actually happen.
2218 + */
2219 + $stored = get_user_meta( $user->ID, 'session_tokens', true );
2220 +
2221 + if ( ! is_array( $stored ) || empty( $stored ) ) {
2222 + return;
2223 + }
2224 +
2225 + $now = time();
2226 + $changed = false;
2227 +
2228 + // Expired sessions are dead weight and count for nothing; drop them first.
2229 + foreach ( $stored as $verifier => $session ) {
2230 + if ( isset( $session['expiration'] ) && (int) $session['expiration'] < $now ) {
2231 + unset( $stored[ $verifier ] );
2232 + $changed = true;
2233 + }
2234 + }
2235 +
2236 + // Oldest first, keeping the current session whatever its login time.
2237 + uasort( $stored, function ( $a, $b ) {
2238 + return ( $a['login'] ?? 0 ) <=> ( $b['login'] ?? 0 );
1964 2239 } );
1965 2240
1966 - $sessions_to_remove = $session_count - $max_sessions;
1967 - $removed = 0;
2241 + $sessions_to_remove = count( $stored ) - $max_sessions;
2242 + $removed = 0;
1968 2243
1969 - foreach ( $all_sessions as $token_hash => $session ) {
2244 + foreach ( $stored as $verifier => $session ) {
1970 2245 if ( $removed >= $sessions_to_remove ) {
1971 2246 break;
1972 2247 }
1973 - // Don't remove current session
1974 - if ( ! $this->is_current_session( $token_hash ) ) {
1975 - $sessions->destroy( $token_hash );
1976 - $removed++;
2248 + if ( $this->is_current_session( $verifier ) ) {
2249 + continue;
1977 2250 }
2251 + unset( $stored[ $verifier ] );
2252 + $removed++;
2253 + $changed = true;
1978 2254 }
1979 2255
2256 + if ( $changed ) {
2257 + update_user_meta( $user->ID, 'session_tokens', $stored );
2258 + }
2259 +
1980 2260 // Log
1981 2261 if ( $this->activity_log && $removed > 0 ) {
1982 2262 $this->activity_log->log(
1983 2263 'user',
@@ -2027,9 +2307,15 @@
2027 2307
2028 2308 // Honor both affected_roles AND the per-user exclusion list, and
2029 2309 // clear stale flags if the user no longer matches the rules.
2030 2310 if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2031 - if ( get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) {
2311 + // Only on a single site, for the same reason as in
2312 + // force_password_change_redirect(): on a network the flag belongs to
2313 + // the account, and this site's policy says nothing about the site
2314 + // that set it. The 2.11.8 fix only covered that method, and this
2315 + // notice cleared the flag anyway on the next admin page; found by
2316 + // the cross review of 2.11.8.
2317 + if ( ! is_multisite() && get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) {
2032 2318 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2033 2319 }
2034 2320 return;
2035 2321 }
@@ -2092,39 +2378,121 @@
2092 2378 }
2093 2379 }
2094 2380
2095 2381 /**
2096 - * Force redirect to password change page
2382 + * Whether this user must change an expired password before doing anything else
2383 + *
2384 + * The flag alone is not enough: it is re-checked against the current policy,
2385 + * because the admin may have taken the user's role out of affected_roles or
2386 + * added the user to the exclusion list after it was set, which would
2387 + * otherwise lock them in a redirect loop. A stale flag is cleared on a
2388 + * single site; on a network the meta is shared by every site and the policy
2389 + * checked is only this site's, so it is left alone and simply not enforced
2390 + * here (a network-wide rework is the 3.1.0 multisite item).
2391 + *
2392 + * @since 2.11.9
2393 + *
2394 + * @param int $user_id User ID.
2395 + * @return bool
2097 2396 */
2397 + private function must_change_password( $user_id ) {
2398 + if ( ! $user_id ) {
2399 + return false;
2400 + }
2401 +
2402 + $flagged = (bool) get_user_meta( $user_id, 'vigilante_must_change_password', true );
2403 +
2404 + if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2405 + if ( $flagged && ! is_multisite() ) {
2406 + delete_user_meta( $user_id, 'vigilante_must_change_password' );
2407 + }
2408 + return false;
2409 + }
2410 +
2411 + if ( $flagged ) {
2412 + return true;
2413 + }
2414 +
2415 + // The flag is set at interactive login (check_password_expiration on
2416 + // wp_login). A session that authenticates only through REST, XML-RPC or
2417 + // an application password never fires wp_login, so the flag can be
2418 + // absent while the password is in fact expired. Compute it on the fly
2419 + // too, so a non-interactive route is not a way around the block. The
2420 + // computation self-seeds the change date on first sight and never locks
2421 + // out a user who has no record yet (see is_password_expired()).
2422 + return $this->is_password_expired( $user_id );
2423 + }
2424 +
2425 + /**
2426 + * Force a user with an expired password to change it, on wp-admin and AJAX
2427 + *
2428 + * Until 2.11.9 this only redirected wp-admin pages and skipped AJAX, so an
2429 + * expired-password session kept working through admin-ajax, and the REST API
2430 + * and the front end were not covered at all. The wp.org automated review of
2431 + * 2.11.8 flagged it: setting a flag on login is not enforcement if the flag
2432 + * is only read by one redirect. It is now enforced on every entry point,
2433 + * here for wp-admin and AJAX and in the three methods below for REST, the
2434 + * front end and XML-RPC. The only thing an affected user can still do is
2435 + * change the password on profile.php or log out.
2436 + */
2098 2437 public function force_password_change_redirect() {
2099 - if ( ! is_user_logged_in() ) {
2438 + if ( ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) {
2100 2439 return;
2101 2440 }
2102 2441
2103 - // Don't redirect on AJAX or profile page
2442 + // AJAX: a redirect is useless, so the request is refused. Changing the
2443 + // password is a profile.php form POST, not AJAX, so nothing the user
2444 + // needs to fix this is blocked.
2104 2445 if ( wp_doing_ajax() ) {
2105 - return;
2446 + wp_send_json_error(
2447 + array( 'message' => __( 'Your password has expired. Change it in your profile before continuing.', 'vigilante' ) ),
2448 + 403
2449 + );
2106 2450 }
2107 2451
2452 + // profile.php is where the change happens; do not redirect it onto itself.
2108 2453 global $pagenow;
2109 2454 if ( 'profile.php' === $pagenow ) {
2110 2455 return;
2111 2456 }
2112 2457
2113 - $user_id = get_current_user_id();
2114 - $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2458 + wp_safe_redirect( admin_url( 'profile.php#password' ) );
2459 + exit;
2460 + }
2115 2461
2116 - if ( ! $must_change ) {
2117 - return;
2462 + /**
2463 + * Refuse REST API requests from a user whose password has expired
2464 + *
2465 + * @since 2.11.9
2466 + *
2467 + * @param WP_Error|null|true $result Result of the earlier authentication checks.
2468 + * @return WP_Error|null|true
2469 + */
2470 + public function block_expired_password_rest( $result ) {
2471 + // Leave any decision another check already made, and do not act on
2472 + // logged-out requests to public endpoints.
2473 + if ( null !== $result && false !== $result ) {
2474 + return $result;
2118 2475 }
2119 2476
2120 - // Re-validate against current settings: the admin may have removed
2121 - // this user's role from affected_roles or added the user to the
2122 - // excluded list after the flag was set. Without this check the flag
2123 - // outlives the configuration change and locks the user in a redirect
2124 - // loop into profile.php.
2125 - if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2126 - delete_user_meta( $user_id, 'vigilante_must_change_password' );
2477 + if ( is_user_logged_in() && $this->must_change_password( get_current_user_id() ) ) {
2478 + return new WP_Error(
2479 + 'vigilante_password_expired',
2480 + __( 'Your password has expired. Change it in your profile before using the REST API.', 'vigilante' ),
2481 + array( 'status' => 403 )
2482 + );
2483 + }
2484 +
2485 + return $result;
2486 + }
2487 +
2488 + /**
2489 + * Send a user with an expired password to the change page from the front end
2490 + *
2491 + * @since 2.11.9
2492 + */
2493 + public function force_password_change_frontend() {
2494 + if ( is_admin() || ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) {
2127 2495 return;
2128 2496 }
2129 2497
2130 2498 wp_safe_redirect( admin_url( 'profile.php#password' ) );
@@ -2131,8 +2499,40 @@
2131 2499 exit;
2132 2500 }
2133 2501
2134 2502 /**
2503 + * Refuse XML-RPC calls from a user whose password has expired
2504 + *
2505 + * The last of the four non-wp-admin entry points. XML-RPC authenticates on
2506 + * every call with the account credentials (a password or an application
2507 + * password), so a session that never touches wp-admin could keep acting
2508 + * through xmlrpc.php while the password sits expired. Scoped to XML-RPC
2509 + * requests so an ordinary login, which the user needs to reach profile.php,
2510 + * is never blocked here. Runs late on authenticate, after core and the
2511 + * application-password handler have resolved the user.
2512 + *
2513 + * @since 2.11.9
2514 + *
2515 + * @param WP_User|WP_Error|null $user Result of the earlier authentication.
2516 + * @return WP_User|WP_Error|null
2517 + */
2518 + public function block_expired_password_xmlrpc( $user ) {
2519 + if ( ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
2520 + return $user;
2521 + }
2522 +
2523 + if ( $user instanceof WP_User && $this->must_change_password( $user->ID ) ) {
2524 + return new WP_Error(
2525 + 'vigilante_password_expired',
2526 + __( 'Your password has expired. Change it in your profile before using XML-RPC.', 'vigilante' ),
2527 + array( 'status' => 403 )
2528 + );
2529 + }
2530 +
2531 + return $user;
2532 + }
2533 +
2534 + /**
2135 2535 * Whether password expiration rules currently apply to a given user
2136 2536 *
2137 2537 * Used to detect stale flags after the admin changes affected_roles or
2138 2538 * the per-user exclusion list.
@@ -2496,8 +2896,21 @@
2496 2896 *
2497 2897 * @param int $user_id User ID.
2498 2898 */
2499 2899 public function send_verification_email( $user_id ) {
2900 + /*
2901 + * Never send an account that is already verified back to pending. The
2902 + * resend link below reaches this, and while the pending value was
2903 + * unreadable (see the note on the meta write) that was harmless; with
2904 + * the check working, resending for a verified account would lock its
2905 + * owner out of their own site.
2906 + */
2907 + if ( metadata_exists( 'user', $user_id, 'vigilante_email_verified' )
2908 + && get_user_meta( $user_id, 'vigilante_email_verified', true )
2909 + ) {
2910 + return;
2911 + }
2912 +
2500 2913 $user = get_userdata( $user_id );
2501 2914 if ( ! $user ) {
2502 2915 return;
2503 2916 }
@@ -2512,10 +2925,22 @@
2512 2925
2513 2926 // Store token
2514 2927 update_user_meta( $user_id, 'vigilante_verification_token', $token_hash );
2515 2928 update_user_meta( $user_id, 'vigilante_verification_expires', $expires );
2516 - update_user_meta( $user_id, 'vigilante_email_verified', false );
2517 2929
2930 + /*
2931 + * '0' and not false. update_user_meta() stores false as an empty string
2932 + * (maybe_serialize() returns it unchanged and wpdb writes it with %s), and
2933 + * an empty string is what get_user_meta() also returns when there is no
2934 + * row at all. So from the moment this feature existed until 2.11.10 the
2935 + * value written to mean "not verified yet" was read back as "this account
2936 + * predates the feature, let it in", and the branch that blocks the login
2937 + * was unreachable. Found by the file-by-file review of 2.11.10. '0' is
2938 + * falsy in PHP and survives the round trip, and the readers below tell an
2939 + * absent row from a stored one with metadata_exists().
2940 + */
2941 + update_user_meta( $user_id, 'vigilante_email_verified', '0' );
2942 +
2518 2943 // Build verification URL
2519 2944 $verify_url = add_query_arg(
2520 2945 array(
2521 2946 'vigilante_verify' => '1',
@@ -2589,16 +3014,21 @@
2589 3014 if ( is_wp_error( $user ) ) {
2590 3015 return $user;
2591 3016 }
2592 3017
2593 - // Check if email is verified
2594 - $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true );
2595 -
2596 - // If no meta exists, user was created before this feature - allow
2597 - if ( '' === $verified ) {
3018 + /*
3019 + * Only a row that does not exist means "created before this feature".
3020 + * An existing row holding an empty string is an account that older
3021 + * versions marked as pending, and it has to be blocked like any other:
3022 + * reading both the same way is what made this check let everyone in
3023 + * (see send_verification_email()).
3024 + */
3025 + if ( ! metadata_exists( 'user', $user->ID, 'vigilante_email_verified' ) ) {
2598 3026 return $user;
2599 3027 }
2600 3028
3029 + $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true );
3030 +
2601 3031 if ( ! $verified ) {
2602 3032 $settings = $this->options['email_verification'] ?? array();
2603 3033 $allow_resend = ! empty( $settings['allow_resend'] );
2604 3034
@@ -2640,10 +3070,19 @@
2640 3070
2641 3071 // Verify nonce to prevent CSRF and user-ID probing.
2642 3072 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
2643 3073 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_resend_verification_' . $user_id ) ) {
2644 - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2645 - exit;
3074 + /*
3075 + * Nothing is redirected to the login page until the request
3076 + * has proved something, and a bad nonce proves nothing. It
3077 + * used to answer with a redirect to wp_login_url(), which
3078 + * under a custom login URL IS the secret address, so any
3079 + * visitor could read it out of the Location header of a
3080 + * request carrying garbage. Found by the third cross review
3081 + * of 2.11.10. Returning leaves the request to render the page
3082 + * it asked for, which tells nobody anything.
3083 + */
3084 + return;
2646 3085 }
2647 3086
2648 3087 // Rate limiting: allow 1 resend every 5 minutes per user to prevent email spam.
2649 3088 $transient_key = 'vigilante_resend_' . $user_id;
@@ -2664,28 +3103,30 @@
2664 3103 $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
2665 3104 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
2666 3105 $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
2667 3106
3107 + // Same as the resend above: no proof, no redirect, so the Location
3108 + // header cannot be used to read the custom login URL.
2668 3109 if ( ! $user_id || ! $token ) {
2669 - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2670 - exit;
3110 + return;
2671 3111 }
2672 3112
2673 - $stored_hash = get_user_meta( $user_id, 'vigilante_verification_token', true );
2674 - $expires = get_user_meta( $user_id, 'vigilante_verification_expires', true );
3113 + $stored_hash = (string) get_user_meta( $user_id, 'vigilante_verification_token', true );
3114 + $expires = (int) get_user_meta( $user_id, 'vigilante_verification_expires', true );
2675 3115
2676 - // Check expiration
3116 + // The token first. Checking the expiry before it answered "expired" for
3117 + // any account with no verification pending and "invalid" for one waiting,
3118 + // so a wrong link revealed which user ids were waiting (2.11.8). Only the
3119 + // holder of the right token learns that it expired.
3120 + if ( '' === $stored_hash || ! hash_equals( $stored_hash, wp_hash( $token ) ) ) {
3121 + return;
3122 + }
3123 +
2677 3124 if ( time() > $expires ) {
2678 3125 wp_safe_redirect( add_query_arg( 'vigilante_message', 'expired', wp_login_url() ) );
2679 3126 exit;
2680 3127 }
2681 3128
2682 - // Verify token
2683 - if ( ! hash_equals( $stored_hash, wp_hash( $token ) ) ) {
2684 - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2685 - exit;
2686 - }
2687 -
2688 3129 // Mark as verified
2689 3130 update_user_meta( $user_id, 'vigilante_email_verified', true );
2690 3131 delete_user_meta( $user_id, 'vigilante_verification_token' );
2691 3132 delete_user_meta( $user_id, 'vigilante_verification_expires' );
@@ -2706,12 +3147,11 @@
2706 3147 'info'
2707 3148 );
2708 3149 }
2709 3150
2710 - // Check if user still needs approval
2711 - $is_pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
2712 -
2713 - if ( $is_pending ) {
3151 + // Anywhere on the network, so the message matches what will actually
3152 + // happen at the login: that is what blocks (see is_pending_anywhere()).
3153 + if ( self::is_pending_anywhere( $user_id ) ) {
2714 3154 // User verified but still pending approval
2715 3155 wp_safe_redirect(
2716 3156 add_query_arg(
2717 3157 array(
@@ -2772,15 +3212,17 @@
2772 3212 * @param int $user_id User ID.
2773 3213 * @return bool
2774 3214 */
2775 3215 public function is_email_verified( $user_id ) {
2776 - $verified = get_user_meta( $user_id, 'vigilante_email_verified', true );
2777 -
2778 - // If no meta exists, consider verified (old users)
2779 - if ( '' === $verified ) {
3216 + // Same reading as block_unverified_user_login(): only an absent row means
3217 + // the account predates the feature. A stored empty string is an account
3218 + // an older version left pending.
3219 + if ( ! metadata_exists( 'user', $user_id, 'vigilante_email_verified' ) ) {
2780 3220 return true;
2781 3221 }
2782 3222
3223 + $verified = get_user_meta( $user_id, 'vigilante_email_verified', true );
3224 +
2783 3225 return (bool) $verified;
2784 3226 }
2785 3227
2786 3228 /* =========================================================================
@@ -2802,9 +3244,9 @@
2802 3244
2803 3245 // Check if this user's role requires approval
2804 3246 $needs_approval = false;
2805 3247 if ( ! empty( $registration_approval['enabled'] ) ) {
2806 - $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
3248 + $affected_roles = Vigilante_Settings::list_or_shipped( $registration_approval['affected_roles'] ?? array(), array( 'user_security', 'registration_approval', 'affected_roles' ) );
2807 3249 $needs_approval = ! empty( array_intersect( $user->roles, $affected_roles ) );
2808 3250 }
2809 3251
2810 3252 // Check if email verification is enabled
@@ -2916,9 +3358,9 @@
2916 3358 if ( empty( $registration_approval['enabled'] ) ) {
2917 3359 return false;
2918 3360 }
2919 3361
2920 - $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
3362 + $affected_roles = Vigilante_Settings::list_or_shipped( $registration_approval['affected_roles'] ?? array(), array( 'user_security', 'registration_approval', 'affected_roles' ) );
2921 3363 return ! empty( array_intersect( $user->roles, $affected_roles ) );
2922 3364 }
2923 3365
2924 3366 /**