Flood control on reset password form blocks IP address but not the account

Created on 1 August 2022, over 2 years ago
Updated 28 August 2024, 5 months ago

This issue has been approved for public discussion by the Drupal Security Team.

Problem/Motivation

The IP address blocking system works fine, but if the reset password for the same account is made from a different IP address, the user is still able to do the same number of attempts until the new IP address gets blocked.

In the validateForm of UserPasswordForm, these 3 lines currently don't do anything, since a simple return in validateForm doesn't trigger any error.

if (!$this->flood->isAllowed('user.password_request_user', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier)) {
    return;
}

The setError has been removed as part of https://www.drupal.org/node/1521996 โ†’ which ultimately changed the behaviour and left redundant code (as stated before, the lines above don't do anything). And currently there is no Change Request to put the behaviour described here https://www.drupal.org/node/3085704 โ†’ back in place.

Steps to reproduce

1. Go to the reset password form
2. Attempt to reset the password for a given account 5 times (ip_limit changed to 5 for testing purposes)
3. After these 5 attempts, the "IP blocked" error message appears
4. Change the IP address (via VPN for instance)
5. Do a new attempt on the same account
6. The same amount of attempts can be done before getting the new IP blocked

Proposed resolution

The account should be appropriately blocked according to the user_limit value. In our example, the account should be blocked at the same time as the first IP address being blocked, since it is set by default to 5.
- Put back the message but with a more generic sentence, so it is unclear if an account exists or not.

if (!$this->flood->isAllowed('user.password_request_user', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier)) {
$form_state->setErrorByName('name', $this->t('Too many password recovery requests. Try again later or contact the site administrator.'));
    return;
}
๐Ÿ› Bug report
Status

Needs work

Version

11.0 ๐Ÿ”ฅ

Component
User systemย  โ†’

Last updated 1 day ago

Created by

Live updates comments and jobs are added and updated live.
  • Security improvements

    It makes Drupal less vulnerable to abuse or misuse. Note, this is the preferred tag, though the Security tag has a large body of issues tagged to it. Do NOT publicly disclose security vulnerabilities; contact the security team instead. Anyone (whether security team or not) can apply this tag to security improvements that do not directly present a vulnerability e.g. hardening an API to add filtering to reduce a common mistake in contributed modules.

Sign in to follow issues

Merge Requests

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

  • ๐Ÿ‡ณ๐Ÿ‡ฟNew Zealand luke.stewart

    So If I understand the changes I think this is what happened:

    Previously attempts per account name were recorded and if they were above the limit set the account was blocked.

    However this means that you can test for the existence of an account by triggering the block. So this was removed.

    So even if we put a generic message back - that still yields information about the attempt as because it's done within a test to see if the user exists - it's only triggered when a username exists.

    So if we want the behaviour back whereby you can trigger a block based on attempting to reset an account separate from the IP block either:

    1) We'd need to block the account but not notify the user that got blocked.
    2) We'd need to record all attempted usernames in a way that we could block by username even if the username wasn't a username corresponding to a user.

    I don't think we could assume that any non existant username is equivalent. Although this would be a nice way to speed up blocking attempts at enumerating usernames it would be possible to use this to identify valid usernames.for example. If you trigger the block and get notified on say 5 usernames that don't exist - and then retry after you are unblocked with 4 that you know don't exist and another username - you would get blocked (and notified) if and only if the fourth didn't exist. Thereby allowing you to test. Although I guess this is complicated by that this behaviour would be influence by any use of the form.

    I feel like 1) is worse behaviour. As this would mean that a legitimate user would not be notified they were blocked, leading to confusion as to why they were not getting a password reset email.

    For 2 things to consider:

    Currently we use the UID in the flood as an identifier - this doesn't work for usernames that don't have a UID because they don't exist.
    One option would be to store a list of attempted usernames and a unique id (perhaps a negative integer as this would never be a UID?)
    But that seems excessive we could store the UID for existing accounts, and the username for attempts on accounts that don't exist.

    What happens if a username that doesn't exist is blocked - is then created. At that point we'd want to remove any block. I think if we split storage between uid for usernames that exist and username for ones that don't we don't have to worry about this.

    The only downside I can see with storing non existing usernames and user ids is that it would mean that the storage would be different so would that mean you could use a timing attack to work out if a username exists? Is this something that is worth considering?

    So if we wanted to go with option 2 is all that is required to add back a generic error message, and for submissions that valid to meet the test:
    if ($account && $account->id() && $account->isActive()) {
    call isAllowed with the provided username as the indentifier and if disallowed set the same message.

  • First commit to issue fork.
  • Pipeline finished with Failed
    5 months ago
    Total: 405s
    #267052
  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia vinmayiswamy

    Hi, Iโ€™ve reproduced this issue on both Drupal 10.3.x and 11.x with PHP 8.3 and have made the proposed changes to address the issue. Hereโ€™s a summary of the updates:

    1. Flood Control Improvement: Updated the password reset functionality to ensure that both the IP address and the user account are simultaneously blocked after exceeding the allowed number of attempts. This ensures that the account is secured regardless of the IP address used.
    2. Error Message: As proposed, updated the error message to be more generic, enhancing security by not revealing whether the account exists.

    Attaching before & after screenshots of the "Error Message" update for reference.

    Due to my local environment setup, I was unable to change my IP address dynamically. Instead, I manually updated the IP address in the database to verify the behavior. The changes appear to resolve the issue as intended. However, it would be greatly appreciated if further testing could be conducted in an environment where IP changes can be performed to confirm the fix.

    Kindly review the changes and let me know if any further adjustments or updates are needed.

    Thanks!

  • Status changed to Needs review 5 months ago
  • Status changed to Needs work 5 months ago
  • ๐Ÿ‡ฎ๐Ÿ‡นItaly apaderno Brescia, ๐Ÿ‡ฎ๐Ÿ‡น

    This is the code in the merge request, including the changed part.

        // Try to load by email.
        $users = $this->userStorage->loadByProperties(['mail' => $name]);
        if (empty($users)) {
          // No success, try to load by name.
          $users = $this->userStorage->loadByProperties(['name' => $name]);
        }
        $account = reset($users);
        // Blocked accounts cannot request a new password.
        if ($account && $account->id() && $account->isActive()) {
          // Register flood events based on the uid only, so they apply for any
          // IP address. This allows them to be cleared on successful reset (from
          // any IP).
          $identifier = $account->id();
          if (!$this->flood->isAllowed('user.password_request_user', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier)) {
          $ip_blocked = !$this->flood->isAllowed('user.password_request_ip', $flood_config->get('ip_limit'), $flood_config->get('ip_window'));
          $user_blocked = !$this->flood->isAllowed('user.password_request_user', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier);
          if ($ip_blocked || $user_blocked) {
            // Block the account and show a generic error message.
            $this->flood->register('user.password_request_user', $flood_config->get('user_window'), $identifier);
            // Add the error message to inform the user.
            $form_state->setErrorByName('name', $this->t('Too many password recovery requests. Try again later or contact the site administrator.'));
            return;
          }
          $this->flood->register('user.password_request_user', $flood_config->get('user_window'), $identifier);
          $form_state->setValueForElement(['#parents' => ['account']], $account);
        }
    

    Since the error is shown after checking an account has been loaded from the database, that error message is indeed saying the account exists. There is no need for the message to explicitly report the username; I know well which username I entered.

  • Pipeline finished with Success
    5 months ago
    Total: 626s
    #267083
  • ๐Ÿ‡ฎ๐Ÿ‡นItaly apaderno Brescia, ๐Ÿ‡ฎ๐Ÿ‡น

    When the code does not show any error for the name form element, it could mean that either there is no account with the entered username, or that account has been blocked. That is the only case where the code does not give any hint about a possible account with the entered username.

  • ๐Ÿ‡ฎ๐Ÿ‡ณIndia vinmayiswamy

    Hi @avpaderno,

    Thank you for your feedback.

    I understand that when no error is shown for the name form element, it might imply that either the account does not exist or it has been blocked, which could potentially reveal hints about the account status.

    Given this context, should we consider adjustments to the "if()" condition mentioned in comment #9 ๐Ÿ› Flood control on reset password form blocks IP address but not the account Needs work , or do you recommend another approach? Iโ€™m seeking confirmation as the "if()" condition (if ($account && $account->id() && $account->isActive()) { /* */ }) was part of the existing code and not introduced by my recent changes.

    Kindly please let me know how youโ€™d like to proceed.

    Thanks!

Production build 0.71.5 2024