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 +179 -28 2.11.12 → 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
@@ -1341,9 +1351,14 @@
1341 1351 return;
1342 1352 }
1343 1353
1344 1354 $settings = $this->options['registration_approval'] ?? array();
1345 - $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 + );
1346 1361
1347 1362 // Check if user role requires approval
1348 1363 $user_roles = $user->roles;
1349 1364 $needs_approval = array_intersect( $user_roles, $affected_roles );
@@ -2307,11 +2322,13 @@
2307 2322 }
2308 2323 return;
2309 2324 }
2310 2325
2311 - // Check if must change password
2312 - $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2313 - 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 ) ) {
2314 2331 global $pagenow;
2315 2332 $on_profile = ( 'profile.php' === $pagenow );
2316 2333 ?>
2317 2334 <div class="notice notice-error">
@@ -2318,8 +2335,9 @@
2318 2335 <p>
2319 2336 <strong><?php esc_html_e( 'Password change required', 'vigilante' ); ?></strong>
2320 2337 <?php if ( $on_profile ) : ?>
2321 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>
2322 2340 <?php else : ?>
2323 2341 <?php
2324 2342 printf(
2325 2343 /* translators: %s: Link to profile */
@@ -2338,28 +2356,40 @@
2338 2356 <?php
2339 2357 return;
2340 2358 }
2341 2359
2342 - // 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.
2343 2365 $days_left = $this->get_days_until_expiration( $user_id );
2344 2366 $warning_days = absint( $settings['warning_days'] ?? 14 );
2345 2367
2346 - if ( $days_left > 0 && $days_left <= $warning_days ) {
2368 + if ( $warning_days > 0 && $days_left >= 0 && $days_left <= $warning_days ) {
2347 2369 ?>
2348 2370 <div class="notice notice-warning is-dismissible">
2349 2371 <p>
2350 2372 <?php
2351 - printf(
2352 - /* translators: 1: Number of days, 2: Link to profile */
2353 - esc_html( _n(
2354 - 'Your password will expire in %1$d day. Please %2$s.',
2355 - 'Your password will expire in %1$d days. Please %2$s.',
2356 - $days_left,
2357 - 'vigilante'
2358 - ) ),
2359 - absint( $days_left ),
2360 - '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>'
2361 - );
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 + }
2362 2392 ?>
2363 2393 </p>
2364 2394 </div>
2365 2395 <?php
@@ -2410,8 +2440,63 @@
2410 2440 return $this->is_password_expired( $user_id );
2411 2441 }
2412 2442
2413 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 + /**
2414 2499 * Force a user with an expired password to change it, on wp-admin and AJAX
2415 2500 *
2416 2501 * Until 2.11.9 this only redirected wp-admin pages and skipped AJAX, so an
2417 2502 * expired-password session kept working through admin-ajax, and the REST API
@@ -2419,9 +2504,11 @@
2419 2504 * 2.11.8 flagged it: setting a flag on login is not enforcement if the flag
2420 2505 * is only read by one redirect. It is now enforced on every entry point,
2421 2506 * here for wp-admin and AJAX and in the three methods below for REST, the
2422 2507 * front end and XML-RPC. The only thing an affected user can still do is
2423 - * 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().
2424 2511 */
2425 2512 public function force_password_change_redirect() {
2426 2513 if ( ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) {
2427 2514 return;
@@ -2436,15 +2523,46 @@
2436 2523 403
2437 2524 );
2438 2525 }
2439 2526
2440 - // profile.php is where the change happens; do not redirect it onto itself.
2441 - global $pagenow;
2442 - 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() ) {
2443 2530 return;
2444 2531 }
2445 2532
2446 - 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 );
2447 2565 exit;
2448 2566 }
2449 2567
2450 2568 /**
@@ -2482,13 +2600,46 @@
2482 2600 if ( is_admin() || ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) {
2483 2601 return;
2484 2602 }
2485 2603
2486 - wp_safe_redirect( admin_url( 'profile.php#password' ) );
2487 - exit;
2604 + $this->redirect_to_profile();
2488 2605 }
2489 2606
2490 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 + /**
2491 2642 * Refuse XML-RPC calls from a user whose password has expired
2492 2643 *
2493 2644 * The last of the four non-wp-admin entry points. XML-RPC authenticates on
2494 2645 * every call with the account credentials (a password or an application
@@ -3232,9 +3383,9 @@
3232 3383
3233 3384 // Check if this user's role requires approval
3234 3385 $needs_approval = false;
3235 3386 if ( ! empty( $registration_approval['enabled'] ) ) {
3236 - $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' ) );
3237 3388 $needs_approval = ! empty( array_intersect( $user->roles, $affected_roles ) );
3238 3389 }
3239 3390
3240 3391 // Check if email verification is enabled
@@ -3346,9 +3497,9 @@
3346 3497 if ( empty( $registration_approval['enabled'] ) ) {
3347 3498 return false;
3348 3499 }
3349 3500
3350 - $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' ) );
3351 3502 return ! empty( array_intersect( $user->roles, $affected_roles ) );
3352 3503 }
3353 3504
3354 3505 /**