PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
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-user-security.php +303 -66 2.11.93.0.0 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
@@ -165,11 +203,11 @@
165 203 add_filter( 'registration_redirect', array( $this, 'custom_registration_redirect' ) );
166 204 add_action( 'login_message', array( $this, 'show_registration_pending_message' ) );
167 205 }
168 206
169 - // Force password reset login message (always active, independent of settings)
170 - add_filter( 'authenticate', array( $this, 'check_force_reset_on_login' ), 30, 3 );
171 - 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();
172 210 }
173 211
174 212 /**
175 213 * Validate username on profile update
@@ -1211,22 +1249,27 @@
1211 1249 * @param string $password Password.
1212 1250 * @return WP_User|WP_Error|null
1213 1251 */
1214 1252 public function check_force_reset_on_login( $user, $username, $password ) {
1215 - // Resolve the target user. The flag must be evaluated whether the
1216 - // credentials matched (WP_User) or not (WP_Error).
1217 - if ( $user instanceof WP_User ) {
1218 - $login_user = $user;
1219 - } else {
1220 - $login_user = get_user_by( 'login', $username );
1221 - if ( ! $login_user ) {
1222 - $login_user = get_user_by( 'email', $username );
1223 - }
1253 + /*
1254 + * Only a login that would otherwise have succeeded is turned into this
1255 + * rejection. Wrong credentials are left exactly as WordPress reported
1256 + * them, and they are counted like any other failed login.
1257 + *
1258 + * Until 2.11.12 this ran for a WP_Error too, resolving the account from
1259 + * the username, and replaced an incorrect_password with the rejection
1260 + * below. A rejection of Vigilant's own is not counted towards the brute
1261 + * force lockout, so any account with a pending forced reset could be
1262 + * guessed at without limit: measured on 17 Sep 2026 against 2.11.11 and
1263 + * against the first build of 2.11.12, six wrong passwords in a row, none
1264 + * of them counted and no lockout at the end. The message this function
1265 + * exists to show belongs to whoever typed the right password.
1266 + */
1267 + if ( ! ( $user instanceof WP_User ) ) {
1268 + return $user;
1224 1269 }
1225 1270
1226 - if ( ! $login_user ) {
1227 - return $user;
1228 - }
1271 + $login_user = $user;
1229 1272
1230 1273 // Check if this user has a pending forced reset.
1231 1274 $force_reset = get_user_meta( $login_user->ID, 'vigilante_force_reset_pending', true );
1232 1275 if ( ! $force_reset ) {
@@ -1232,17 +1275,11 @@
1232 1275 if ( ! $force_reset ) {
1233 1276 return $user;
1234 1277 }
1235 1278
1236 - // If credentials were wrong with an error other than incorrect_password
1237 - // (e.g. a Vigilant lockout, pending approval), don't shadow it.
1238 - if ( is_wp_error( $user ) && ! in_array( 'incorrect_password', $user->get_error_codes(), true ) ) {
1239 - return $user;
1240 - }
1279 + // Not counted towards the brute force lockout: the rejection is
1280 + // recognised by its error code (Vigilante_Login_Security::CONTROLLED_REJECTIONS).
1241 1281
1242 - // Skip brute force counter for this controlled rejection.
1243 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1244 -
1245 1282 // Surface the controlled rejection in the activity log so the admin
1246 1283 // can tell apart "user fails login because they typed wrong password"
1247 1284 // from "user fails login because we are forcing a reset".
1248 1285 if ( $this->activity_log ) {
@@ -1314,11 +1351,11 @@
1314 1351 if ( empty( $needs_approval ) ) {
1315 1352 return;
1316 1353 }
1317 1354
1318 - // Set pending status
1319 - update_user_meta( $user_id, 'vigilante_pending_approval', true );
1320 - update_user_meta( $user_id, 'vigilante_pending_since', time() );
1355 + // Set pending status, on this site only (see site_user_meta_key()).
1356 + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true );
1357 + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ), time() );
1321 1358
1322 1359 // Log
1323 1360 if ( $this->activity_log ) {
1324 1361 $this->activity_log->log(
@@ -1351,14 +1388,9 @@
1351 1388 if ( is_wp_error( $user ) ) {
1352 1389 return $user;
1353 1390 }
1354 1391
1355 - $is_pending = get_user_meta( $user->ID, 'vigilante_pending_approval', true );
1356 -
1357 - if ( $is_pending ) {
1358 - // Mark this as a controlled rejection (not a brute force attempt)
1359 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1360 -
1392 + if ( self::is_pending_anywhere( $user->ID ) ) {
1361 1393 return new WP_Error(
1362 1394 'pending_approval',
1363 1395 __( '<strong>Account pending:</strong> Your account is awaiting administrator approval. You will receive an email once approved.', 'vigilante' )
1364 1396 );
@@ -1407,16 +1439,131 @@
1407 1439 <?php
1408 1440 }
1409 1441
1410 1442 /**
1443 + * A user meta key that belongs to one site, even on a network
1444 + *
1445 + * Registration approval is a per-site setting, but user meta is network
1446 + * wide, so a single global key made the pending queue shared: an
1447 + * administrator of one site saw, approved and rejected accounts waiting on
1448 + * another, and clearing the flag cleared it for the whole network. Reported
1449 + * by the wp.org automated review of 2.11.9.
1450 + *
1451 + * On a network the key carries the blog prefix, the way core does with
1452 + * capabilities (wp_2_capabilities), so each site keeps its own queue. On a
1453 + * single site the key is returned unchanged, so nothing has to be migrated
1454 + * there and the stored data of every existing install keeps working.
1455 + *
1456 + * @since 2.11.10
1457 + *
1458 + * Note the default is null and not 0: wpdb::get_blog_prefix() reads null as
1459 + * "the current blog", and 0 as the main site, so passing 0 here gave every
1460 + * subsite the key of the main site and kept the queue shared. Caught by
1461 + * matriz-red-repaso-21110.sh before this shipped.
1462 + *
1463 + * @param string $key Base meta key.
1464 + * @param int|null $blog_id Blog to build it for. Current blog when null.
1465 + * @return string
1466 + */
1467 + public static function site_user_meta_key( $key, $blog_id = null ) {
1468 + global $wpdb;
1469 +
1470 + if ( ! is_multisite() ) {
1471 + return $key;
1472 + }
1473 +
1474 + return $wpdb->get_blog_prefix( $blog_id ) . $key;
1475 + }
1476 +
1477 + /**
1478 + * Whether this account is waiting for approval on ANY site of the network
1479 + *
1480 + * The queue is per site and stays per site, because approving somebody is a
1481 + * decision of the site they signed up to. Blocking them is a different
1482 + * question with a different answer, and giving it the same one was a hole:
1483 + * the session cookie WordPress issues is valid on every host of the network
1484 + * (COOKIE_DOMAIN and COOKIEPATH, wp-includes/ms-default-constants.php:58-59
1485 + * and :84-88), so an account held back on demo1 logged in through the main
1486 + * site, where it carried no flag, and walked straight back into demo1 with
1487 + * that cookie. Reproduced over HTTP by the second cross review of 2.11.10.
1488 + * It is the same reasoning that two_factor_required_for() already applies:
1489 + * network-wide cookie, network-wide enforcement.
1490 + *
1491 + * Read from the account's own meta in one pass rather than by asking site by
1492 + * site, so the cost does not grow with the network. The legacy key with no
1493 + * prefix is included because the migration that moves it runs on the first
1494 + * admin page load and until then a waiting account has to keep being
1495 + * blocked; reading both fails closed.
1496 + *
1497 + * @since 2.11.10
1498 + *
1499 + * @param int $user_id User ID.
1500 + * @return bool
1501 + */
1502 + public static function is_pending_anywhere( $user_id ) {
1503 + global $wpdb;
1504 +
1505 + if ( get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) {
1506 + return true;
1507 + }
1508 +
1509 + if ( ! is_multisite() ) {
1510 + return false;
1511 + }
1512 +
1513 + $all = get_user_meta( $user_id );
1514 +
1515 + if ( ! is_array( $all ) ) {
1516 + return false;
1517 + }
1518 +
1519 + $pattern = '/^' . preg_quote( $wpdb->base_prefix, '/' ) . '(\d+_)?vigilante_pending_approval$/';
1520 +
1521 + foreach ( $all as $key => $values ) {
1522 + if ( ! preg_match( $pattern, $key, $m ) ) {
1523 + continue;
1524 + }
1525 +
1526 + /*
1527 + * A mark left behind by a site that no longer exists asks nobody for
1528 + * anything: deleting a subsite does not touch this plugin's user meta,
1529 + * so the account stayed blocked on the whole network with no queue
1530 + * anywhere to clear it from, in a plugin whose users have no WP-CLI.
1531 + * Found by the third cross review of 2.11.10. get_site() is cached, so
1532 + * this costs nothing in the usual case of no leftovers.
1533 + */
1534 + if ( ! empty( $m[1] ) && ! get_site( (int) rtrim( $m[1], '_' ) ) ) {
1535 + continue;
1536 + }
1537 +
1538 + foreach ( (array) $values as $value ) {
1539 + if ( ! empty( $value ) ) {
1540 + return true;
1541 + }
1542 + }
1543 + }
1544 +
1545 + return false;
1546 + }
1547 +
1548 + /**
1411 1549 * Get pending users
1412 1550 *
1551 + * The meta key is what scopes this list to the current site, so on a network
1552 + * the query deliberately does not add the site's own membership filter on
1553 + * top. Core's WP_User_Query turns the default into "{$prefix}capabilities
1554 + * EXISTS" (wp-includes/class-wp-user-query.php:598-604), and an account that
1555 + * is waiting for approval can perfectly well have no role yet: it then held
1556 + * this site's flag, was blocked from logging in, and appeared in no queue at
1557 + * all, so nobody could ever approve or reject it. Found by the second cross
1558 + * review of 2.11.10.
1559 + *
1413 1560 * @return array Array of pending user objects.
1414 1561 */
1415 1562 public function get_pending_users() {
1416 1563 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
1417 1564 $args = array(
1418 - 'meta_key' => 'vigilante_pending_approval',
1565 + 'meta_key' => self::site_user_meta_key( 'vigilante_pending_approval' ),
1419 1566 'meta_value' => '1',
1420 1567 'orderby' => 'registered',
1421 1568 'order' => 'DESC',
1422 1569 );
@@ -1421,8 +1568,12 @@
1421 1568 'order' => 'DESC',
1422 1569 );
1423 1570 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1424 1571
1572 + if ( is_multisite() ) {
1573 + $args['blog_id'] = 0;
1574 + }
1575 +
1425 1576 return get_users( $args );
1426 1577 }
1427 1578
1428 1579 /**
@@ -1434,9 +1585,11 @@
1434 1585 */
1435 1586 public function approve_user( $user_id, $approved_by = 0 ) {
1436 1587 // Same reasoning as reject_user(): approving an account that never asked
1437 1588 // for approval is a no-op that reports success and writes misleading meta.
1438 - if ( ! get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) {
1589 + // Only this site's flag counts, so approving never clears the queue of
1590 + // another site of the network (see site_user_meta_key()).
1591 + if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) {
1439 1592 return false;
1440 1593 }
1441 1594
1442 1595 $user = get_userdata( $user_id );
@@ -1443,12 +1596,15 @@
1443 1596 if ( ! $user ) {
1444 1597 return false;
1445 1598 }
1446 1599
1447 - delete_user_meta( $user_id, 'vigilante_pending_approval' );
1448 - delete_user_meta( $user_id, 'vigilante_pending_since' );
1449 - update_user_meta( $user_id, 'vigilante_approved_by', $approved_by );
1450 - update_user_meta( $user_id, 'vigilante_approved_date', time() );
1600 + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) );
1601 + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) );
1602 + // Por sitio como las dos de arriba: quien aprueba y cuando es un hecho de
1603 + // la cola de ESTE sitio, y dejarlas globales hacia que una aprobacion
1604 + // pisara el registro de otro (cierra B4 de la revision cruzada).
1605 + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_by' ), $approved_by );
1606 + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_date' ), time() );
1451 1607
1452 1608 // Log
1453 1609 if ( $this->activity_log ) {
1454 1610 $admin = $approved_by ? get_userdata( $approved_by ) : null;
@@ -1489,9 +1645,9 @@
1489 1645 // Only an account actually waiting for approval may be rejected. Without
1490 1646 // this the handler deletes any user id it is given, and wp_delete_user()
1491 1647 // with no reassignment takes their posts with them, skipping the dialog
1492 1648 // core always shows. Deleting a member is the Users screen's job.
1493 - if ( ! get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) {
1649 + if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) {
1494 1650 return false;
1495 1651 }
1496 1652
1497 1653 // Log before deletion
@@ -1518,8 +1674,21 @@
1518 1674
1519 1675 // Send rejection email before deleting
1520 1676 $this->send_rejection_email( $user, $reason );
1521 1677
1678 + /*
1679 + * The mark goes first, because on a network the account may well survive
1680 + * the deletion: wp_delete_user() only calls remove_user_from_blog() there
1681 + * (wp-admin/includes/user.php:440-442), which clears the role and nothing
1682 + * of this plugin's own meta. Leaving it behind made Reject a loop with no
1683 + * way out: the account stayed blocked on every site of the network, the
1684 + * row never left the queue (which since 2.11.10 no longer hides accounts
1685 + * without a role), and pressing Reject again sent the rejection email once
1686 + * more and reported success. Found by the third cross review of 2.11.10.
1687 + */
1688 + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) );
1689 + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) );
1690 +
1522 1691 // Delete user
1523 1692 require_once ABSPATH . 'wp-admin/includes/user.php';
1524 1693 return wp_delete_user( $user_id );
1525 1694 }
@@ -1901,8 +2070,31 @@
1901 2070 return max( 0, $count );
1902 2071 }
1903 2072
1904 2073 /**
2074 + * Whether the session store this limit would act on belongs to a whole network
2075 + *
2076 + * WP_Session_Tokens keeps session_tokens in the usermeta table, which is
2077 + * network wide, while this limit is configured per site. So on a network a
2078 + * site administrator setting a low limit would count, and with close_oldest
2079 + * close, the sessions the same user opened on other sites, including an
2080 + * administrator session elsewhere; and block_new would refuse a login over
2081 + * sessions that have nothing to do with this site. Reported by the wp.org
2082 + * automated review of 2.11.9, on the close_oldest half.
2083 + *
2084 + * Until the network-wide policy of 3.1.0, the limit simply does not apply on
2085 + * a network, and the settings screen says so. On a single site nothing
2086 + * changes: there the session store and the setting cover the same thing.
2087 + *
2088 + * @since 2.11.10
2089 + *
2090 + * @return bool
2091 + */
2092 + public static function session_limit_is_network_wide() {
2093 + return is_multisite();
2094 + }
2095 +
2096 + /**
1905 2097 * Check session limit before login completes (for block_new behavior)
1906 2098 *
1907 2099 * @param WP_User $user User object.
1908 2100 * @param string $password Password.
@@ -1912,8 +2104,12 @@
1912 2104 if ( is_wp_error( $user ) ) {
1913 2105 return $user;
1914 2106 }
1915 2107
2108 + if ( self::session_limit_is_network_wide() ) {
2109 + return $user;
2110 + }
2111 +
1916 2112 $settings = $this->options['session_limits'] ?? array();
1917 2113 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
1918 2114 $exclude_admins = ! empty( $settings['exclude_admins'] );
1919 2115
@@ -1943,11 +2139,8 @@
1943 2139 'warning'
1944 2140 );
1945 2141 }
1946 2142
1947 - // Mark this as a controlled rejection (not a brute force attempt)
1948 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1949 -
1950 2143 return new WP_Error(
1951 2144 'session_limit_exceeded',
1952 2145 sprintf(
1953 2146 /* translators: %d: Maximum sessions allowed */
@@ -1966,8 +2159,12 @@
1966 2159 * @param string $user_login Username.
1967 2160 * @param WP_User $user User object.
1968 2161 */
1969 2162 public function enforce_session_limit( $user_login, $user ) {
2163 + if ( self::session_limit_is_network_wide() ) {
2164 + return;
2165 + }
2166 +
1970 2167 $settings = $this->options['session_limits'] ?? array();
1971 2168 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
1972 2169 $behavior = $settings['behavior'] ?? 'block_new';
1973 2170 $exclude_admins = ! empty( $settings['exclude_admins'] );
@@ -2687,8 +2884,21 @@
2687 2884 *
2688 2885 * @param int $user_id User ID.
2689 2886 */
2690 2887 public function send_verification_email( $user_id ) {
2888 + /*
2889 + * Never send an account that is already verified back to pending. The
2890 + * resend link below reaches this, and while the pending value was
2891 + * unreadable (see the note on the meta write) that was harmless; with
2892 + * the check working, resending for a verified account would lock its
2893 + * owner out of their own site.
2894 + */
2895 + if ( metadata_exists( 'user', $user_id, 'vigilante_email_verified' )
2896 + && get_user_meta( $user_id, 'vigilante_email_verified', true )
2897 + ) {
2898 + return;
2899 + }
2900 +
2691 2901 $user = get_userdata( $user_id );
2692 2902 if ( ! $user ) {
2693 2903 return;
2694 2904 }
@@ -2703,10 +2913,22 @@
2703 2913
2704 2914 // Store token
2705 2915 update_user_meta( $user_id, 'vigilante_verification_token', $token_hash );
2706 2916 update_user_meta( $user_id, 'vigilante_verification_expires', $expires );
2707 - update_user_meta( $user_id, 'vigilante_email_verified', false );
2708 2917
2918 + /*
2919 + * '0' and not false. update_user_meta() stores false as an empty string
2920 + * (maybe_serialize() returns it unchanged and wpdb writes it with %s), and
2921 + * an empty string is what get_user_meta() also returns when there is no
2922 + * row at all. So from the moment this feature existed until 2.11.10 the
2923 + * value written to mean "not verified yet" was read back as "this account
2924 + * predates the feature, let it in", and the branch that blocks the login
2925 + * was unreachable. Found by the file-by-file review of 2.11.10. '0' is
2926 + * falsy in PHP and survives the round trip, and the readers below tell an
2927 + * absent row from a stored one with metadata_exists().
2928 + */
2929 + update_user_meta( $user_id, 'vigilante_email_verified', '0' );
2930 +
2709 2931 // Build verification URL
2710 2932 $verify_url = add_query_arg(
2711 2933 array(
2712 2934 'vigilante_verify' => '1',
@@ -2780,16 +3002,21 @@
2780 3002 if ( is_wp_error( $user ) ) {
2781 3003 return $user;
2782 3004 }
2783 3005
2784 - // Check if email is verified
2785 - $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true );
2786 -
2787 - // If no meta exists, user was created before this feature - allow
2788 - if ( '' === $verified ) {
3006 + /*
3007 + * Only a row that does not exist means "created before this feature".
3008 + * An existing row holding an empty string is an account that older
3009 + * versions marked as pending, and it has to be blocked like any other:
3010 + * reading both the same way is what made this check let everyone in
3011 + * (see send_verification_email()).
3012 + */
3013 + if ( ! metadata_exists( 'user', $user->ID, 'vigilante_email_verified' ) ) {
2789 3014 return $user;
2790 3015 }
2791 3016
3017 + $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true );
3018 +
2792 3019 if ( ! $verified ) {
2793 3020 $settings = $this->options['email_verification'] ?? array();
2794 3021 $allow_resend = ! empty( $settings['allow_resend'] );
2795 3022
@@ -2831,10 +3058,19 @@
2831 3058
2832 3059 // Verify nonce to prevent CSRF and user-ID probing.
2833 3060 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
2834 3061 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_resend_verification_' . $user_id ) ) {
2835 - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2836 - exit;
3062 + /*
3063 + * Nothing is redirected to the login page until the request
3064 + * has proved something, and a bad nonce proves nothing. It
3065 + * used to answer with a redirect to wp_login_url(), which
3066 + * under a custom login URL IS the secret address, so any
3067 + * visitor could read it out of the Location header of a
3068 + * request carrying garbage. Found by the third cross review
3069 + * of 2.11.10. Returning leaves the request to render the page
3070 + * it asked for, which tells nobody anything.
3071 + */
3072 + return;
2837 3073 }
2838 3074
2839 3075 // Rate limiting: allow 1 resend every 5 minutes per user to prevent email spam.
2840 3076 $transient_key = 'vigilante_resend_' . $user_id;
@@ -2855,11 +3091,12 @@
2855 3091 $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
2856 3092 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
2857 3093 $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
2858 3094
3095 + // Same as the resend above: no proof, no redirect, so the Location
3096 + // header cannot be used to read the custom login URL.
2859 3097 if ( ! $user_id || ! $token ) {
2860 - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2861 - exit;
3098 + return;
2862 3099 }
2863 3100
2864 3101 $stored_hash = (string) get_user_meta( $user_id, 'vigilante_verification_token', true );
2865 3102 $expires = (int) get_user_meta( $user_id, 'vigilante_verification_expires', true );
@@ -2868,10 +3105,9 @@
2868 3105 // any account with no verification pending and "invalid" for one waiting,
2869 3106 // so a wrong link revealed which user ids were waiting (2.11.8). Only the
2870 3107 // holder of the right token learns that it expired.
2871 3108 if ( '' === $stored_hash || ! hash_equals( $stored_hash, wp_hash( $token ) ) ) {
2872 - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2873 - exit;
3109 + return;
2874 3110 }
2875 3111
2876 3112 if ( time() > $expires ) {
2877 3113 wp_safe_redirect( add_query_arg( 'vigilante_message', 'expired', wp_login_url() ) );
@@ -2899,12 +3135,11 @@
2899 3135 'info'
2900 3136 );
2901 3137 }
2902 3138
2903 - // Check if user still needs approval
2904 - $is_pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
2905 -
2906 - if ( $is_pending ) {
3139 + // Anywhere on the network, so the message matches what will actually
3140 + // happen at the login: that is what blocks (see is_pending_anywhere()).
3141 + if ( self::is_pending_anywhere( $user_id ) ) {
2907 3142 // User verified but still pending approval
2908 3143 wp_safe_redirect(
2909 3144 add_query_arg(
2910 3145 array(
@@ -2965,14 +3200,16 @@
2965 3200 * @param int $user_id User ID.
2966 3201 * @return bool
2967 3202 */
2968 3203 public function is_email_verified( $user_id ) {
2969 - $verified = get_user_meta( $user_id, 'vigilante_email_verified', true );
2970 -
2971 - // If no meta exists, consider verified (old users)
2972 - if ( '' === $verified ) {
3204 + // Same reading as block_unverified_user_login(): only an absent row means
3205 + // the account predates the feature. A stored empty string is an account
3206 + // an older version left pending.
3207 + if ( ! metadata_exists( 'user', $user_id, 'vigilante_email_verified' ) ) {
2973 3208 return true;
2974 3209 }
3210 +
3211 + $verified = get_user_meta( $user_id, 'vigilante_email_verified', true );
2975 3212
2976 3213 return (bool) $verified;
2977 3214 }
2978 3215