PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
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 +198 -54 2.11.11 → 3.0.5 View file →
@@ -173,8 +173,11 @@
173 173 // Enforcement beyond wp-admin: REST and the front end, so an expired
174 174 // password cannot keep operating outside the redirect (2.11.9).
175 175 add_filter( 'rest_authentication_errors', array( $this, 'block_expired_password_rest' ), 20 );
176 176 add_action( 'template_redirect', array( $this, 'force_password_change_frontend' ) );
177 + // The profile is the only screen where the change can be made, and
178 + // WooCommerce keeps accounts with no back-end role out of wp-admin.
179 + add_filter( 'woocommerce_prevent_admin_access', array( $this, 'allow_profile_for_password_change' ) );
177 180 add_filter( 'authenticate', array( $this, 'block_expired_password_xmlrpc' ), 30, 1 );
178 181 add_action( 'profile_update', array( $this, 'update_password_change_date' ), 10, 2 );
179 182 add_action( 'user_register', array( $this, 'set_initial_password_date' ) );
180 183 add_action( 'user_profile_update_errors', array( $this, 'check_password_history' ), 10, 3 );
@@ -284,9 +287,11 @@
284 287 * @return bool
285 288 */
286 289 private function is_insecure_username( $username ) {
287 290 $username = strtolower( trim( $username ) );
288 - $insecure_usernames = $this->options['insecure_usernames'] ?? array();
291 + // Vigilante_Settings::list_or_shipped(): an empty list of a setting with
292 + // no field on screen is a leftover, not a choice. See its docblock.
293 + $insecure_usernames = $this->get_insecure_usernames();
289 294
290 295 return in_array( $username, array_map( 'strtolower', $insecure_usernames ), true );
291 296 }
292 297
@@ -988,9 +993,14 @@
988 993 *
989 994 * @return array
990 995 */
991 996 public function get_insecure_usernames() {
992 - return $this->options['insecure_usernames'] ?? array();
997 + // The single reader of this list, so what blocks and what the screen
998 + // reports can never disagree.
999 + return Vigilante_Settings::list_or_shipped(
1000 + $this->options['insecure_usernames'] ?? array(),
1001 + array( 'user_security', 'insecure_usernames' )
1002 + );
993 1003 }
994 1004
995 1005 /**
996 1006 * Check for existing insecure admin users
@@ -1249,22 +1259,27 @@
1249 1259 * @param string $password Password.
1250 1260 * @return WP_User|WP_Error|null
1251 1261 */
1252 1262 public function check_force_reset_on_login( $user, $username, $password ) {
1253 - // Resolve the target user. The flag must be evaluated whether the
1254 - // credentials matched (WP_User) or not (WP_Error).
1255 - if ( $user instanceof WP_User ) {
1256 - $login_user = $user;
1257 - } else {
1258 - $login_user = get_user_by( 'login', $username );
1259 - if ( ! $login_user ) {
1260 - $login_user = get_user_by( 'email', $username );
1261 - }
1263 + /*
1264 + * Only a login that would otherwise have succeeded is turned into this
1265 + * rejection. Wrong credentials are left exactly as WordPress reported
1266 + * them, and they are counted like any other failed login.
1267 + *
1268 + * Until 2.11.12 this ran for a WP_Error too, resolving the account from
1269 + * the username, and replaced an incorrect_password with the rejection
1270 + * below. A rejection of Vigilant's own is not counted towards the brute
1271 + * force lockout, so any account with a pending forced reset could be
1272 + * guessed at without limit: measured on 17 Sep 2026 against 2.11.11 and
1273 + * against the first build of 2.11.12, six wrong passwords in a row, none
1274 + * of them counted and no lockout at the end. The message this function
1275 + * exists to show belongs to whoever typed the right password.
1276 + */
1277 + if ( ! ( $user instanceof WP_User ) ) {
1278 + return $user;
1262 1279 }
1263 1280
1264 - if ( ! $login_user ) {
1265 - return $user;
1266 - }
1281 + $login_user = $user;
1267 1282
1268 1283 // Check if this user has a pending forced reset.
1269 1284 $force_reset = get_user_meta( $login_user->ID, 'vigilante_force_reset_pending', true );
1270 1285 if ( ! $force_reset ) {
@@ -1270,17 +1285,11 @@
1270 1285 if ( ! $force_reset ) {
1271 1286 return $user;
1272 1287 }
1273 1288
1274 - // If credentials were wrong with an error other than incorrect_password
1275 - // (e.g. a Vigilant lockout, pending approval), don't shadow it.
1276 - if ( is_wp_error( $user ) && ! in_array( 'incorrect_password', $user->get_error_codes(), true ) ) {
1277 - return $user;
1278 - }
1289 + // Not counted towards the brute force lockout: the rejection is
1290 + // recognised by its error code (Vigilante_Login_Security::CONTROLLED_REJECTIONS).
1279 1291
1280 - // Skip brute force counter for this controlled rejection.
1281 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1282 -
1283 1292 // Surface the controlled rejection in the activity log so the admin
1284 1293 // can tell apart "user fails login because they typed wrong password"
1285 1294 // from "user fails login because we are forcing a reset".
1286 1295 if ( $this->activity_log ) {
@@ -1342,9 +1351,14 @@
1342 1351 return;
1343 1352 }
1344 1353
1345 1354 $settings = $this->options['registration_approval'] ?? array();
1346 - $affected_roles = $settings['affected_roles'] ?? array( 'subscriber' );
1355 + // Vigilante_Settings::list_or_shipped(): an empty list of a setting with
1356 + // no field on screen is a leftover, not a choice. See its docblock.
1357 + $affected_roles = Vigilante_Settings::list_or_shipped(
1358 + $settings['affected_roles'] ?? array(),
1359 + array( 'user_security', 'registration_approval', 'affected_roles' )
1360 + );
1347 1361
1348 1362 // Check if user role requires approval
1349 1363 $user_roles = $user->roles;
1350 1364 $needs_approval = array_intersect( $user_roles, $affected_roles );
@@ -1390,11 +1404,8 @@
1390 1404 return $user;
1391 1405 }
1392 1406
1393 1407 if ( self::is_pending_anywhere( $user->ID ) ) {
1394 - // Mark this as a controlled rejection (not a brute force attempt)
1395 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1396 -
1397 1408 return new WP_Error(
1398 1409 'pending_approval',
1399 1410 __( '<strong>Account pending:</strong> Your account is awaiting administrator approval. You will receive an email once approved.', 'vigilante' )
1400 1411 );
@@ -2143,11 +2154,8 @@
2143 2154 'warning'
2144 2155 );
2145 2156 }
2146 2157
2147 - // Mark this as a controlled rejection (not a brute force attempt)
2148 - add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
2149 -
2150 2158 return new WP_Error(
2151 2159 'session_limit_exceeded',
2152 2160 sprintf(
2153 2161 /* translators: %d: Maximum sessions allowed */
@@ -2314,11 +2322,13 @@
2314 2322 }
2315 2323 return;
2316 2324 }
2317 2325
2318 - // Check if must change password
2319 - $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2320 - if ( $must_change ) {
2326 + // Ask what the redirect asks. From 2.11.9 to 3.0.3 this read only the
2327 + // flag, which is written at login, while force_password_change_redirect()
2328 + // also works the expiry out on the spot: a session still open when the
2329 + // password expired was sent to the profile with no notice saying why.
2330 + if ( $this->must_change_password( $user_id ) ) {
2321 2331 global $pagenow;
2322 2332 $on_profile = ( 'profile.php' === $pagenow );
2323 2333 ?>
2324 2334 <div class="notice notice-error">
@@ -2325,8 +2335,9 @@
2325 2335 <p>
2326 2336 <strong><?php esc_html_e( 'Password change required', 'vigilante' ); ?></strong>
2327 2337 <?php if ( $on_profile ) : ?>
2328 2338 <?php esc_html_e( 'Your password has expired. Set a new password in the section below and save your profile to continue.', 'vigilante' ); ?>
2339 + <a href="#password"><?php esc_html_e( 'Go to the password field', 'vigilante' ); ?></a>
2329 2340 <?php else : ?>
2330 2341 <?php
2331 2342 printf(
2332 2343 /* translators: %s: Link to profile */
@@ -2345,28 +2356,40 @@
2345 2356 <?php
2346 2357 return;
2347 2358 }
2348 2359
2349 - // Show warning if expiring soon
2360 + // Show warning if expiring soon. get_days_until_expiration() rounds down,
2361 + // so 0 here is less than a day left: an expired password never gets this
2362 + // far, must_change_password() has answered for it above. Until 3.0.3 the
2363 + // check asked for more than 0, so the last day showed nothing and the
2364 + // next thing the user saw was the forced change.
2350 2365 $days_left = $this->get_days_until_expiration( $user_id );
2351 2366 $warning_days = absint( $settings['warning_days'] ?? 14 );
2352 2367
2353 - if ( $days_left > 0 && $days_left <= $warning_days ) {
2368 + if ( $warning_days > 0 && $days_left >= 0 && $days_left <= $warning_days ) {
2354 2369 ?>
2355 2370 <div class="notice notice-warning is-dismissible">
2356 2371 <p>
2357 2372 <?php
2358 - printf(
2359 - /* translators: 1: Number of days, 2: Link to profile */
2360 - esc_html( _n(
2361 - 'Your password will expire in %1$d day. Please %2$s.',
2362 - 'Your password will expire in %1$d days. Please %2$s.',
2363 - $days_left,
2364 - 'vigilante'
2365 - ) ),
2366 - absint( $days_left ),
2367 - '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>'
2368 - );
2373 + if ( $days_left < 1 ) {
2374 + printf(
2375 + /* translators: %s: Link to profile */
2376 + esc_html__( 'Your password expires in less than a day. Please %s.', 'vigilante' ),
2377 + '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>'
2378 + );
2379 + } else {
2380 + printf(
2381 + /* translators: 1: Number of days, 2: Link to profile */
2382 + esc_html( _n(
2383 + 'Your password will expire in %1$d day. Please %2$s.',
2384 + 'Your password will expire in %1$d days. Please %2$s.',
2385 + $days_left,
2386 + 'vigilante'
2387 + ) ),
2388 + absint( $days_left ),
2389 + '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>'
2390 + );
2391 + }
2369 2392 ?>
2370 2393 </p>
2371 2394 </div>
2372 2395 <?php
@@ -2417,8 +2440,63 @@
2417 2440 return $this->is_password_expired( $user_id );
2418 2441 }
2419 2442
2420 2443 /**
2444 + * Whether this request is the user's own profile form and nothing else
2445 + *
2446 + * A user who must change an expired password is let through to the profile,
2447 + * because that is where the change is made. Until 3.0.3 "the profile" meant
2448 + * the name of the script, and profile.php can be asked for other things: the
2449 + * admin bootstrap runs a plugin page or an importer named in the address
2450 + * whatever the script is, and fires the hook of any action it is sent
2451 + * (wp-admin/admin.php), and the profile script edits whichever user the
2452 + * request names (wp-admin/user-edit.php). The pending change could be put
2453 + * off while part of the admin area was still in use.
2454 + *
2455 + * Two requests are the profile form: the one that shows it, which posts
2456 + * nothing, and the one that saves it carrying the nonce WordPress printed
2457 + * on this user's own form. Parameters in the address that WordPress does
2458 + * nothing with are left alone, so a proxy or a browser extension that adds
2459 + * one to every address does not send the user round in circles.
2460 + *
2461 + * Meant for admin_init. wp-admin/profile.php defines IS_PROFILE_PAGE before
2462 + * it loads the admin bootstrap, so the constant is there when that script is
2463 + * the one running. The network admin and user dashboard copies load the
2464 + * bootstrap first: from them the user is taken to the site profile, where
2465 + * the notice is shown.
2466 + *
2467 + * @return bool
2468 + */
2469 + private function is_own_profile_request() {
2470 + global $pagenow, $plugin_page;
2471 +
2472 + if ( 'profile.php' !== $pagenow || ! defined( 'IS_PROFILE_PAGE' ) || ! IS_PROFILE_PAGE ) {
2473 + return false;
2474 + }
2475 +
2476 + // A plugin page or an importer named in the address.
2477 + if ( isset( $plugin_page ) || isset( $_GET['import'] ) ) {
2478 + return false;
2479 + }
2480 +
2481 + // Somebody else's profile.
2482 + $own_id = (string) get_current_user_id();
2483 + if ( ! empty( $_REQUEST['user_id'] ) && $own_id !== $_REQUEST['user_id'] ) {
2484 + return false;
2485 + }
2486 +
2487 + // No action: the form being shown, which posts nothing.
2488 + if ( empty( $_REQUEST['action'] ) ) {
2489 + return empty( $_POST );
2490 + }
2491 +
2492 + // An action: only the form being saved.
2493 + return 'update' === $_REQUEST['action']
2494 + && isset( $_REQUEST['_wpnonce'] )
2495 + && false !== wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ), 'update-user_' . $own_id );
2496 + }
2497 +
2498 + /**
2421 2499 * Force a user with an expired password to change it, on wp-admin and AJAX
2422 2500 *
2423 2501 * Until 2.11.9 this only redirected wp-admin pages and skipped AJAX, so an
2424 2502 * expired-password session kept working through admin-ajax, and the REST API
@@ -2426,9 +2504,11 @@
2426 2504 * 2.11.8 flagged it: setting a flag on login is not enforcement if the flag
2427 2505 * is only read by one redirect. It is now enforced on every entry point,
2428 2506 * here for wp-admin and AJAX and in the three methods below for REST, the
2429 2507 * front end and XML-RPC. The only thing an affected user can still do is
2430 - * change the password on profile.php or log out.
2508 + * change the password on profile.php or log out. From 2.11.9 to 3.0.3 that
2509 + * was not quite true, because any request to profile.php was let through:
2510 + * see is_own_profile_request().
2431 2511 */
2432 2512 public function force_password_change_redirect() {
2433 2513 if ( ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) {
2434 2514 return;
@@ -2443,15 +2523,46 @@
2443 2523 403
2444 2524 );
2445 2525 }
2446 2526
2447 - // profile.php is where the change happens; do not redirect it onto itself.
2448 - global $pagenow;
2449 - if ( 'profile.php' === $pagenow ) {
2527 + // The profile form is where the change happens; do not redirect it onto
2528 + // itself. Anything else profile.php is asked for goes back to the form.
2529 + if ( $this->is_own_profile_request() ) {
2450 2530 return;
2451 2531 }
2452 2532
2453 - wp_safe_redirect( admin_url( 'profile.php#password' ) );
2533 + $this->redirect_to_profile();
2534 + }
2535 +
2536 + /**
2537 + * Send a user who must change an expired password to their profile
2538 + *
2539 + * To the top of it, with no "#password" fragment (it had one from 1.0.0 to
2540 + * 3.0.3): the browser jumped straight to the password row and left the
2541 + * notice that says why, printed at the top of the page, out of sight. The
2542 + * notice carries the link to the field instead.
2543 + *
2544 + * wp_safe_redirect() answers an address it does not take for the site's
2545 + * own with a redirect to admin_url(), the dashboard, and the dashboard sent
2546 + * the user here again, and so on until the browser gave up. It only knows
2547 + * the home host, so that happened on a site whose WordPress lives on a host
2548 + * of its own, and also where the host has non-ASCII letters stored as
2549 + * written or is an IPv6 literal. The address sent is built here from the
2550 + * site's own settings and carries nothing from the request, so the fallback
2551 + * for this one call is that same address: what admin_url() says is as good
2552 + * for the profile as it is for the dashboard WordPress falls back to.
2553 + */
2554 + private function redirect_to_profile() {
2555 + $profile = admin_url( 'profile.php' );
2556 +
2557 + add_filter(
2558 + 'wp_safe_redirect_fallback',
2559 + static function () use ( $profile ) {
2560 + return $profile;
2561 + }
2562 + );
2563 +
2564 + wp_safe_redirect( $profile );
2454 2565 exit;
2455 2566 }
2456 2567
2457 2568 /**
@@ -2489,13 +2600,46 @@
2489 2600 if ( is_admin() || ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) {
2490 2601 return;
2491 2602 }
2492 2603
2493 - wp_safe_redirect( admin_url( 'profile.php#password' ) );
2494 - exit;
2604 + $this->redirect_to_profile();
2495 2605 }
2496 2606
2497 2607 /**
2608 + * Let a user who must change an expired password reach the profile in a WooCommerce store
2609 + *
2610 + * WooCommerce sends accounts with no back-end role (a customer, a subscriber)
2611 + * from wp-admin to My Account, and force_password_change_frontend() sends
2612 + * them from there back to the profile. With one of those roles in Affected
2613 + * Roles the two redirects chased each other until the browser gave up, from
2614 + * 2.11.9 to 3.0.3, and the account could neither use the store nor change
2615 + * the password.
2616 + *
2617 + * WooCommerce's redirect is lifted on the profile and nowhere else, and only
2618 + * while the change is pending: from any other wp-admin screen the account
2619 + * still ends up on the profile, and once the new password is saved
2620 + * WooCommerce sends it back to My Account as it always does.
2621 + *
2622 + * What this depends on is not the caller's to choose: the user comes from
2623 + * the session, whether the change is pending from must_change_password(),
2624 + * and the request has to be the profile form and nothing else, which is
2625 + * is_own_profile_request(), the same test the redirect uses. What it gives
2626 + * is the user's own profile, which WordPress opens to any account that can
2627 + * read (wp-admin/menu.php); WooCommerce's redirect is the store's
2628 + * preference, not a permission.
2629 + *
2630 + * @param bool $prevent_access Whether WooCommerce is about to send this request out of wp-admin.
2631 + * @return bool
2632 + */
2633 + public function allow_profile_for_password_change( $prevent_access ) {
2634 + if ( $prevent_access && is_user_logged_in() && $this->is_own_profile_request() && $this->must_change_password( get_current_user_id() ) ) {
2635 + return false;
2636 + }
2637 +
2638 + return $prevent_access;
2639 + }
2640 +
2641 + /**
2498 2642 * Refuse XML-RPC calls from a user whose password has expired
2499 2643 *
2500 2644 * The last of the four non-wp-admin entry points. XML-RPC authenticates on
2501 2645 * every call with the account credentials (a password or an application
@@ -3239,9 +3383,9 @@
3239 3383
3240 3384 // Check if this user's role requires approval
3241 3385 $needs_approval = false;
3242 3386 if ( ! empty( $registration_approval['enabled'] ) ) {
3243 - $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
3387 + $affected_roles = Vigilante_Settings::list_or_shipped( $registration_approval['affected_roles'] ?? array(), array( 'user_security', 'registration_approval', 'affected_roles' ) );
3244 3388 $needs_approval = ! empty( array_intersect( $user->roles, $affected_roles ) );
3245 3389 }
3246 3390
3247 3391 // Check if email verification is enabled
@@ -3353,9 +3497,9 @@
3353 3497 if ( empty( $registration_approval['enabled'] ) ) {
3354 3498 return false;
3355 3499 }
3356 3500
3357 - $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
3501 + $affected_roles = Vigilante_Settings::list_or_shipped( $registration_approval['affected_roles'] ?? array(), array( 'user_security', 'registration_approval', 'affected_roles' ) );
3358 3502 return ! empty( array_intersect( $user->roles, $affected_roles ) );
3359 3503 }
3360 3504
3361 3505 /**