Username timing attack on recover password/MFA token in vantage6
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.
| Package | Affected versions | Patched versions |
|---|---|---|
vantage6PyPI | < 4.3.0 | 4.3.0 |
Affected products
1Patches
1aecfd6d0e831Merge pull request from GHSA-5h3x-6gwf-73jm
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- github.com/advisories/GHSA-5h3x-6gwf-73jmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-24770ghsaADVISORY
- github.com/vantage6/vantage6/commit/aecfd6d0e83165a41a60ebd52d2287b0217be26bghsax_refsource_MISCWEB
- github.com/vantage6/vantage6/security/advisories/GHSA-45gq-q4xh-cp53ghsax_refsource_MISCWEB
- github.com/vantage6/vantage6/security/advisories/GHSA-5h3x-6gwf-73jmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.