VYPR
Moderate severityNVD Advisory· Published Mar 14, 2024· Updated Aug 26, 2024

Username timing attack on recover password/MFA token in vantage6

CVE-2024-24770

Description

vantage6 is an open source framework built to enable, manage and deploy privacy enhancing technologies like Federated Learning and Multi-Party Computation. Much like GHSA-45gq-q4xh-cp53, it is possible to find which usernames exist in vantage6 by calling the API routes /recover/lost and /2fa/lost. These routes send emails to users if they have lost their password or MFA token. This issue has been addressed in commit aecfd6d0e and is expected to ship in subsequent releases. Users are advised to upgrade as soon as a new release is available. There are no known workarounds for this vulnerability.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
vantage6PyPI
< 4.3.04.3.0

Affected products

1

Patches

1
aecfd6d0e831

Merge pull request from GHSA-5h3x-6gwf-73jm

https://github.com/vantage6/vantage6Bart van BeusekomMar 7, 2024via ghsa
6 files changed · +149 120
  • docs/server/yaml/server_config.yaml+7 4 modified
    @@ -166,14 +166,17 @@ password_policy:
       # login attempts is reached. Default is 15.
       inactivation_minutes: 15
     
    -  # number of minutes to wait between emails that alert a user that someone is
    -  # trying to log in to their account. Default is 60.
    -  between_email_blocked_login_minutes: 60
    +  # number of minutes to wait between emails sent to the user for each of the following events:
    +  #  - their account has been blocked (max login attempts exceeded)
    +  #  - a password reset request via email
    +  #  - a 2FA reset request via email
    +  # (these events have an independent timer). Default is 60.
    +  between_user_emails_minutes: 60
     
     # set up with which origins the server should allow CORS requests. The default
     # is to allow all origins. If you want to restrict this, you can specify a list
     # of origins here. Below are examples to allow requests from the Cotopaxi UI, and
     #  port 3456 on localhost
     cors_allowed_origins:
       - https://portal.cotopaxi.vantage6.ai
    -  - http://localhost:3456
    +  - http://localhost:3456
    \ No newline at end of file
    
  • vantage6-server/vantage6/server/globals.py+1 1 modified
    @@ -70,4 +70,4 @@
     # default password policies
     DEFAULT_MAX_FAILED_ATTEMPTS = 5
     DEFAULT_INACTIVATION_MINUTES = 15
    -DEFAULT_BETWEEN_BLOCKED_LOGIN_EMAIL_MINUTES = 60
    +DEFAULT_BETWEEN_USER_EMAILS_MINUTES = 60
    
  • vantage6-server/vantage6/server/model/user.py+1 0 modified
    @@ -67,6 +67,7 @@ class User(Authenticatable):
         last_login_attempt = Column(DateTime)
         otp_secret = Column(String(32))
         last_email_failed_login_sent = Column(DateTime)
    +    last_email_recover_password_sent = Column(DateTime)
     
         # relationships
         organization = relationship("Organization", back_populates="users")
    
  • vantage6-server/vantage6/server/resource/common/auth_helper.py+3 3 modified
    @@ -13,7 +13,7 @@
         DEFAULT_SUPPORT_EMAIL_ADDRESS,
         DEFAULT_MAX_FAILED_ATTEMPTS,
         DEFAULT_INACTIVATION_MINUTES,
    -    DEFAULT_BETWEEN_BLOCKED_LOGIN_EMAIL_MINUTES,
    +    DEFAULT_BETWEEN_USER_EMAILS_MINUTES,
         DEFAULT_EMAIL_FROM_ADDRESS,
     )
     from vantage6.server.model.user import User
    @@ -197,8 +197,8 @@ def __notify_user_blocked(
         # check that email has not already been sent recently
         password_policy = config.get("password_policy", {})
         minutes_between_blocked_emails = password_policy.get(
    -        "between_email_blocked_login_minutes",
    -        DEFAULT_BETWEEN_BLOCKED_LOGIN_EMAIL_MINUTES,
    +        "between_user_emails_minutes",
    +        DEFAULT_BETWEEN_USER_EMAILS_MINUTES,
         )
         email_sent_recently = user.last_email_failed_login_sent and (
             dt.datetime.now()
    
  • vantage6-server/vantage6/server/resource/common/input_schema.py+13 31 modified
    @@ -134,6 +134,17 @@ def validate_password(self, password: str):
             _validate_password(password)
     
     
    +class BasicAuthInputSchema(Schema):
    +    """Schema for validating input for basic authentication using a username and password."""
    +
    +    username = fields.String(required=True, validate=Length(min=1, max=_MAX_LEN_NAME))
    +    # Note that we don't inherit from _PasswordValidationSchema here and
    +    # don't validate password in case the password does not fulfill the
    +    # password policy. This is e.g. the case with the default root user created
    +    # when the server is started for the first time.
    +    password = fields.String(required=True, validate=Length(min=1, max=_MAX_LEN_PW))
    +
    +
     class CollaborationInputSchema(_NameValidationSchema):
         """Schema for validating input for a creating a collaboration."""
     
    @@ -288,31 +299,9 @@ class ResetPasswordInputSchema(_PasswordValidationSchema):
         reset_token = fields.String(required=True, validate=Length(max=_MAX_LEN_STR_LONG))
     
     
    -class Recover2FAInputSchema(Schema):
    +class Recover2FAInputSchema(BasicAuthInputSchema):
         """Schema for validating input for recovering 2FA."""
     
    -    email = fields.Email()
    -    username = fields.String(validate=Length(max=_MAX_LEN_NAME))
    -    password = fields.String(required=True, validate=Length(min=1, max=_MAX_LEN_PW))
    -
    -    @validates_schema
    -    def validate_email_or_username(self, data: dict, **kwargs) -> None:
    -        """
    -        Validate the input, which should contain either an email or username.
    -
    -        Parameters
    -        ----------
    -        data : dict
    -            The input data. Should contain an email or username.
    -
    -        Raises
    -        ------
    -        ValidationError
    -            If the input does not contain an email or username.
    -        """
    -        if not ("email" in data or "username" in data):
    -            raise ValidationError("Email or username is required")
    -
     
     class Reset2FAInputSchema(Schema):
         """Schema for validating input for resetting 2FA."""
    @@ -438,16 +427,9 @@ def validate_databases(self, databases: list[dict]):
                         f"are {allowed_keys}."
                     )
     
    -
    -class TokenUserInputSchema(Schema):
    +class TokenUserInputSchema(BasicAuthInputSchema):
         """Schema for validating input for creating a token for a user."""
     
    -    username = fields.String(required=True, validate=Length(min=1, max=_MAX_LEN_NAME))
    -    # Note that we don't inherit from _PasswordValidationSchema here and
    -    # don't validate password in case the password does not fulfill the
    -    # password policy. This is e.g. the case with the default root user created
    -    # when the server is started for the first time.
    -    password = fields.String(required=True, validate=Length(min=1, max=_MAX_LEN_PW))
         mfa_code = fields.String(validate=Length(max=10))
     
     
    
  • vantage6-server/vantage6/server/resource/recover.py+124 81 modified
    @@ -2,12 +2,15 @@
     import logging
     import datetime
     
    -from flask import request, render_template, g
    +import gevent
    +from flask import request, render_template, g, current_app, Flask
     from flask_jwt_extended import create_access_token, decode_token
     from flask_restful import Api
    +from flask_mail import Mail
     from jwt.exceptions import DecodeError
     from http import HTTPStatus
     from sqlalchemy.orm.exc import NoResultFound
    +import datetime as dt
     
     from vantage6.common import logger_name, generate_apikey
     from vantage6.common.globals import APPNAME
    @@ -16,6 +19,8 @@
         DEFAULT_EMAILED_TOKEN_VALIDITY_MINUTES,
         DEFAULT_SUPPORT_EMAIL_ADDRESS,
         DEFAULT_EMAIL_FROM_ADDRESS,
    +    DEFAULT_EMAILED_TOKEN_VALIDITY_MINUTES,
    +    DEFAULT_BETWEEN_USER_EMAILS_MINUTES,
     )
     from vantage6.server.resource import ServicesResources, with_user
     from vantage6.server.resource.common.auth_helper import create_qr_uri, user_login
    @@ -27,6 +32,7 @@
         Reset2FAInputSchema,
         ResetAPIKeyInputSchema,
     )
    +from vantage6.server.model.user import User
     
     module_name = logger_name(__name__)
     log = logging.getLogger(module_name)
    @@ -105,6 +111,97 @@ def setup(api: Api, api_base: str, services: dict) -> None:
     change_pw_schema = ChangePasswordInputSchema()
     
     
    +# used by RecoverPassword.post()
    +def _handle_password_recovery(
    +    app: Flask, username: str, email: str, config: dict, mail: Mail
    +) -> None:
    +    """
    +    Send an email to user with a password reset token.
    +
    +    This function also checks whether such an email has been sent recently, and
    +    if so avoids sending it.
    +
    +    Parameters
    +    ----------
    +    app: flask.Flask
    +        The current Flask app
    +    username: str
    +        User for who the password reset is being requested
    +    email: str
    +        Email address associated to an account for which the password reset is
    +        being requested
    +    config: dict
    +        Dictionary with configuration settings
    +    mail: flask_mail.Mail
    +        An instance of the Flask mail class. Used to send email to user in case
    +        of too many failed login attempts.
    +    """
    +    # read settings
    +    password_policy = config.get("password_policy", {})
    +    minutes_between_password_reset_emails = password_policy.get(
    +        "between_user_emails_minutes",
    +        DEFAULT_BETWEEN_USER_EMAILS_MINUTES,
    +    )
    +    smtp_settings = config.get("smtp", {})
    +    minutes_token_valid = smtp_settings.get(
    +        "email_token_validity_minutes", DEFAULT_EMAILED_TOKEN_VALIDITY_MINUTES
    +    )
    +    expires = dt.timedelta(minutes=minutes_token_valid)
    +    email_from = smtp_settings.get("email_from", DEFAULT_EMAIL_FROM_ADDRESS)
    +    support_email = config.get("support_email", DEFAULT_SUPPORT_EMAIL_ADDRESS)
    +
    +    try:
    +        user = User.get_by_username(username) if username else User.get_by_email(email)
    +    except NoResultFound:
    +        account_name = username or email
    +        log.info(
    +            "Someone requested password recovery for non-existing account '%s'",
    +            account_name,
    +        )
    +        return
    +
    +    log.debug("Password reset requested for '%s'", user.username)
    +
    +    # check that email has not already been sent recently
    +    email_sent_recently = user.last_email_recover_password_sent and (
    +        dt.datetime.now()
    +        < user.last_email_recover_password_sent
    +        + dt.timedelta(minutes=minutes_between_password_reset_emails)
    +    )
    +    if email_sent_recently:
    +        log.info("Skipping sending password reset email to '%s'", user.username)
    +        return
    +
    +    with app.app_context():
    +        # generate a token that can reset their password
    +        reset_token = create_access_token({"id": str(user.id)}, expires_delta=expires)
    +        log.info("Sending password reset email to '%s'", user.email)
    +        mail.send_email(
    +            f"Password reset {APPNAME}",
    +            sender=email_from,
    +            recipients=[user.email],
    +            text_body=render_template(
    +                "mail/reset_token.txt",
    +                token=reset_token,
    +                firstname=user.firstname,
    +                reset_type="password",
    +                what_to_do="simply ignore this message",
    +            ),
    +            html_body=render_template(
    +                "mail/reset_token.html",
    +                token=reset_token,
    +                firstname=user.firstname,
    +                reset_type="password",
    +                support_email=support_email,
    +                what_to_do="simply ignore this message",
    +            ),
    +        )
    +
    +    # Update last password reset email sent date
    +    user.last_email_recover_password_sent = dt.datetime.now()
    +    user.save()
    +
    +
     # ------------------------------------------------------------------------------
     # Resources / API's
     # ------------------------------------------------------------------------------
    @@ -222,52 +319,18 @@ def post(self):
             username = body.get("username")
             email = body.get("email")
     
    -        # find user in the database, if not here we stop!
    -        try:
    -            if username:
    -                user = db.User.get_by_username(username)
    -            else:
    -                user = db.User.get_by_email(email)
    -        except NoResultFound:
    -            account_name = email if email else username
    -            log.info(
    -                "Someone request 2FA reset for non-existing account" f" {account_name}"
    -            )
    -            # we do not tell them.... But we won't continue either
    -            return ret
    -
    -        log.info(f"Password reset requested for '{user.username}'")
    -
    -        # generate a token that can reset their password
    -        smtp_settings = self.config.get("smtp", {})
    -        minutes_token_valid = smtp_settings.get(
    -            "email_token_validity_minutes", DEFAULT_EMAILED_TOKEN_VALIDITY_MINUTES
    -        )
    -        expires = datetime.timedelta(minutes=minutes_token_valid)
    -        reset_token = create_access_token({"id": str(user.id)}, expires_delta=expires)
    -
    -        email_from = smtp_settings.get("email_from", DEFAULT_EMAIL_FROM_ADDRESS)
    -        support_email = self.config.get("support_email", DEFAULT_SUPPORT_EMAIL_ADDRESS)
    -
    -        self.mail.send_email(
    -            f"Password reset {APPNAME}",
    -            sender=email_from,
    -            recipients=[user.email],
    -            text_body=render_template(
    -                "mail/reset_token.txt",
    -                token=reset_token,
    -                firstname=user.firstname,
    -                reset_type="password",
    -                what_to_do="simply ignore this message",
    -            ),
    -            html_body=render_template(
    -                "mail/reset_token.html",
    -                token=reset_token,
    -                firstname=user.firstname,
    -                reset_type="password",
    -                support_email=support_email,
    -                what_to_do="simply ignore this message",
    -            ),
    +        log.debug("Scheduling handling of password recovery request")
    +        # we schedule _handle_password_recovery in '3' seconds to make it very
    +        # likely we'll respond to the user's request (HTTP) before we start
    +        # executing its code. We do this to avoid potential timing attacks
    +        gevent.spawn_later(
    +            3,
    +            _handle_password_recovery,
    +            current_app._get_current_object(),
    +            username,
    +            email,
    +            self.config,
    +            self.mail,
             )
     
             return ret
    @@ -332,7 +395,7 @@ def post(self):
             ---
             description: >-
               Request a recover token if two-factor authentication secret is lost.
    -          A password and either email address or username must be supplied.
    +          A password and a username must be supplied.
     
             requestBody:
               content:
    @@ -342,10 +405,6 @@ def post(self):
                       username:
                         type: string
                         description: Username from which the 2fa needs to be reset
    -                  email:
    -                    type: string
    -                    description: Email of user from which the 2fa needs to be
    -                      reset
                       password:
                         type: string
                         description: Password of user whose 2fa needs to be reset
    @@ -358,12 +417,6 @@ def post(self):
     
             tags: ["Account recovery"]
             """
    -        # default return string
    -        ret = {
    -            "msg": "If you sent a correct combination of username/email and"
    -            "password, you will soon receive an email."
    -        }
    -
             # obtain parameters from request
             body = request.get_json()
     
    @@ -376,28 +429,15 @@ def post(self):
                 }, HTTPStatus.BAD_REQUEST
     
             username = body.get("username")
    -        email = body.get("email")
             password = body.get("password")
     
    -        # find user in the database, if not here we stop!
    -        try:
    -            if username:
    -                user = db.User.get_by_username(username)
    -            else:
    -                user = db.User.get_by_email(email)
    -        except NoResultFound:
    -            account_name = email if email else username
    -            log.info(
    -                "Someone request 2FA reset for non-existing account" f" {account_name}"
    -            )
    -            # we do not tell them.... But we won't continue either
    -            return ret, HTTPStatus.OK
    -
    -        # check password
    -        user, code = user_login(self.config, user.username, password, self.mail)
    -        if code != HTTPStatus.OK:
    -            log.error(f"Failed to reset 2FA for user {username}, wrong " "password")
    -            return user, code
    +        # check credentials
    +        user, login_status = user_login(self.config, username, password, self.mail)
    +        if login_status != HTTPStatus.OK:
    +            log.error(f"Failed attempt to reset 2FA for submitted user '%s'", username)
    +            # Note: user_login() returns a dict with an error message if login
    +            #       failed as first returned element ('user')
    +            return user, login_status
     
             log.info(f"2FA reset requested for '{user.username}'")
     
    @@ -421,19 +461,22 @@ def post(self):
                     token=reset_token,
                     firstname=user.firstname,
                     reset_type="two-factor authentication code",
    -                what_to_do=("please reset your password! It has been " "compromised"),
    +                what_to_do=("please reset your password! It has been compromised"),
                 ),
                 html_body=render_template(
                     "mail/reset_token.html",
                     token=reset_token,
                     firstname=user.firstname,
                     reset_type="two-factor authentication code",
                     support_email=support_email,
    -                what_to_do=("please reset your password! It has been " "compromised"),
    +                what_to_do=("please reset your password! It has been compromised"),
                 ),
             )
    +        log.info("2FA reset request email sent for '%s'", user.username)
     
    -        return ret, HTTPStatus.OK
    +        return {
    +            "msg": "You should have received an email that will allow you to reset your 2FA."
    +        }, login_status
     
     
     class ChangePassword(ServicesResources):
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.