CVE-2022-3363: Other (NVD-CWE-Other)
value: Business Logic Errors in GitHub repository ikus060/rdiffweb prior to 2.5.0a7.
CWE description: NVD is only using a subset of CWE for mapping instead of the entire CWE, and the weakness type is not covered by that subset.
CVSS v3 base score: 9.8
Published: 2022-10-26T21:15Z
Repository: https://github.com/ikus060/rdiffweb
Language: Python
Files changed: README.md, rdiffweb/controller/page_mfa.py, rdiffweb/controller/page_pref_mfa.py, rdiffweb/controller/tests/test_page_prefs_mfa.py, rdiffweb/core/config.py, rdiffweb/core/model/_user.py, rdiffweb/core/model/tests/test_user.py, rdiffweb/core/notification.py, rdiffweb/core/tests/test_notification.py, rdiffweb/templates/email_mfa.html, rdiffweb/templates/email_verification_code.html, tox.ini
Commit date: 2022-10-23 15:15:35 -0400
Commit message: Send email notification when enabling or disabling MFA

Diff: diff --git a/README.md b/README.md
index be717e7b1..fa1ba2f33 100644
--- a/README.md
+++ b/README.md
@@ -136,10 +136,11 @@ This next release focus on two-factor-authentication as a measure to increase se
 * Limit incorrect attempts to change the user's password to prevent brute force attacks #225 [CVE-2022-3273](https://nvd.nist.gov/vuln/detail/CVE-2022-3273)
 * Enforce password policy new password cannot be set as new password [CVE-2022-3376](https://nvd.nist.gov/vuln/detail/CVE-2022-3376)
 * Enforce better rate limit on login, mfa, password change and API [CVE-2022-3439](https://nvd.nist.gov/vuln/detail/CVE-2022-3439) [CVE-2022-3456](https://nvd.nist.gov/vuln/detail/CVE-2022-3456)
 * Enforce 'Origin' validation [CVE-2022-3457](https://nvd.nist.gov/vuln/detail/CVE-2022-3457)
 * Define idle and absolute session timeout with agressive default to protect usage on public computer [CVE-2022-3327](https://nvd.nist.gov/vuln/detail/CVE-2022-3327)
+* Send email notification when enabling or disabling MFA [CVE-2022-3363](https://nvd.nist.gov/vuln/detail/CVE-2022-3363)
 
 Breaking changes:
 
 * Drop Ubuntu Hirsute & Impish (End-of-life)
 * `session-dir` is deprecated and should be replace by `rate-limit-dir`. User's session are stored in database.
diff --git a/rdiffweb/controller/page_mfa.py b/rdiffweb/controller/page_mfa.py
index 9b6981d21..017ceaafe 100644
--- a/rdiffweb/controller/page_mfa.py
+++ b/rdiffweb/controller/page_mfa.py
@@ -103,12 +103,12 @@ class MfaPage(Controller):
             flash(
                 _(
                     "Multi-factor authentication is enabled for your account, but your account does not have a valid email address to send the verification code to. Check your account settings with your administrator."
                 )
             )
-        else:
-            code = cherrypy.tools.auth_mfa.generate_code()
-            body = self.app.templates.compile_template(
-                "email_mfa.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code}
-            )
-            cherrypy.engine.publish('queue_mail', to=userobj.email, subject=_("Your verification code"), message=body)
-            flash(_("A new verification code has been sent to your email."))
+            return
+        code = cherrypy.tools.auth_mfa.generate_code()
+        body = self.app.templates.compile_template(
+            "email_verification_code.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code}
+        )
+        cherrypy.engine.publish('queue_mail', to=userobj.email, subject=_("Your verification code"), message=body)
+        flash(_("A new verification code has been sent to your email."))
diff --git a/rdiffweb/controller/page_pref_mfa.py b/rdiffweb/controller/page_pref_mfa.py
index cf4d4295e..918bf8d11 100644
--- a/rdiffweb/controller/page_pref_mfa.py
+++ b/rdiffweb/controller/page_pref_mfa.py
@@ -124,9 +124,9 @@ class PagePrefMfa(Controller):
         if not userobj.email:
             flash(_("To continue, you must set up an email address for your account."), level='warning')
             return
         code = cherrypy.tools.auth_mfa.generate_code()
         body = self.app.templates.compile_template(
-            "email_mfa.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code}
+            "email_verification_code.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code}
         )
         cherrypy.engine.publish('queue_mail', to=userobj.email, subject=_("Your verification code"), message=body)
         flash(_("A new verification code has been sent to your email."))
diff --git a/rdiffweb/controller/tests/test_page_prefs_mfa.py b/rdiffweb/controller/tests/test_page_prefs_mfa.py
index 248e53bd6..97e8e5325 100644
--- a/rdiffweb/controller/tests/test_page_prefs_mfa.py
+++ b/rdiffweb/controller/tests/test_page_prefs_mfa.py
@@ -13,11 +13,11 @@
 # GNU General Public License for more details.
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from unittest.mock import MagicMock
+from unittest.mock import ANY, MagicMock
 
 import cherrypy
 from parameterized import parameterized
 
 import rdiffweb.test
@@ -32,77 +32,81 @@ class PagePrefMfaTest(rdiffweb.test.WebCase):
         super().setUp()
         # Define email for all test
         userobj = UserObject.get_user(self.USERNAME)
         userobj.email = 'admin@example.com'
         userobj.add()
+        # Register a listener on email
+        self.listener = MagicMock()
+        cherrypy.engine.subscribe('queue_mail', self.listener.queue_email, priority=50)
+
+    def tearDown(self):
+        cherrypy.engine.unsubscribe('queue_mail', self.listener.queue_email)
+        return super().tearDown()
 
     def _set_mfa(self, mfa):
         # Define mfa for user
         userobj = UserObject.get_user(self.USERNAME)
         userobj.mfa = mfa
         userobj.add()
+        # Reset mock.
+        self.listener.queue_email.reset_mock()
+        # Leave to disable mfa
         if mfa == UserObject.DISABLED_MFA:
             return
         # Generate a code for login if required
-        self.listener = MagicMock()
-        cherrypy.engine.subscribe('queue_mail', self.listener.queue_email, priority=50)
-        try:
-            self.getPage("/mfa/")
-            self.assertStatus(200)
-            self.assertInBody("A new verification code has been sent to your email.")
-            # Extract code from email between <strong> and </strong>
-            self.listener.queue_email.assert_called_once()
-            message = self.listener.queue_email.call_args[1]['message']
-            code = message.split('<strong>', 1)[1].split('</strong>')[0]
-            # Login to MFA
-            self.getPage("/mfa/", method='POST', body={'code': code, 'submit': '1'})
-            self.assertStatus(303)
-        finally:
-            cherrypy.engine.unsubscribe('queue_mail', self.listener.queue_email)
+        self.getPage("/mfa/")
+        self.assertStatus(200)
+        self.assertInBody("A new verification code has been sent to your email.")
+        # Extract code from email between <strong> and </strong>
+        self.listener.queue_email.assert_called_once()
+        message = self.listener.queue_email.call_args[1]['message']
+        code = message.split('<strong>', 1)[1].split('</strong>')[0]
+        # Login to MFA
+        self.getPage("/mfa/", method='POST', body={'code': code, 'submit': '1'})
+        self.assertStatus(303)
+        # Clear mock.
+        self.listener.queue_email.reset_mock()
 
     def _get_code(self, action):
         assert action in ['enable_mfa', 'disable_mfa', 'resend_code']
-        # Register an email listeer to capture email send
-        self.listener = MagicMock()
-        cherrypy.engine.subscribe('queue_mail', self.listener.queue_email, priority=50)
         # Query MFA page to generate a code
-        try:
-            self.getPage("/prefs/mfa", method='POST', body={action: '1'})
-            self.assertStatus(200)
-            self.assertInBody("A new verification code has been sent to your email.")
-            # Extract code from email between <strong> and </strong>
-            self.listener.queue_email.assert_called_once()
-            message = self.listener.queue_email.call_args[1]['message']
-            return message.split('<strong>', 1)[1].split('</strong>')[0]
-        finally:
-            cherrypy.engine.unsubscribe('queue_mail', self.listener.queue_email)
+        self.getPage("/prefs/mfa", method='POST', body={action: '1'})
+        self.assertStatus(200)
+        self.assertInBody("A new verification code has been sent to your email.")
+        # Extract code from email between <strong> and </strong>
+        self.listener.queue_email.assert_called_once()
+        message = self.listener.queue_email.call_args[1]['message']
+        self.listener.queue_email.reset_mock()
+        return message.split('<strong>', 1)[1].split('</strong>')[0]
 
     def test_get(self):
         # When getting the page
         self.getPage("/prefs/mfa")
         # Then the page is return without error
         self.assertStatus(200)
 
     @parameterized.expand(
         [
-            ('enable_mfa', UserObject.DISABLED_MFA, UserObject.ENABLED_MFA),
-            ('disable_mfa', UserObject.ENABLED_MFA, UserObject.DISABLED_MFA),
+            ('enable_mfa', UserObject.DISABLED_MFA, UserObject.ENABLED_MFA, "Two-Factor Authentication turned on"),
+            ('disable_mfa', UserObject.ENABLED_MFA, UserObject.DISABLED_MFA, "Two-Factor Authentication turned off"),
         ]
     )
-    def test_with_valid_code(self, action, initial_mfa, expected_mfa):
+    def test_with_valid_code(self, action, initial_mfa, expected_mfa, expected_subject):
         # Define mfa for user
         self._set_mfa(initial_mfa)
         # Given a user with email requesting a code
         code = self._get_code(action=action)
         # When sending a valid code
         self.getPage("/prefs/mfa", method='POST', body={action: '1', 'code': code})
         # Then mfa get enabled or disable accordingly
         self.assertStatus(200)
         userobj = UserObject.get_user(self.USERNAME)
         self.assertEqual(userobj.mfa, expected_mfa)
-        # Then no email get sent
+        # Then no verification code get sent
         self.assertNotInBody("A new verification code has been sent to your email.")
+        # Then an email confirmation get send
+        self.listener.queue_email.assert_called_once_with(to=ANY, subject=expected_subject, message=ANY)
         # Then next page request is still working.
         self.getPage('/')
         self.assertStatus(200)
 
     @parameterized.expand(
diff --git a/rdiffweb/core/config.py b/rdiffweb/core/config.py
index b560ab86a..6b2cbc7e0 100644
--- a/rdiffweb/core/config.py
+++ b/rdiffweb/core/config.py
@@ -157,11 +157,11 @@ def get_parser():
     parser.add_argument(
         '--email-send-changed-notification',
         '--emailsendchangednotification',
         help='True to send notification when sensitive information get change in user profile.',
         action='store_true',
-        default=False,
+        default=True,
     )
 
     parser.add_argument(
         '--favicon',
         help='location of an icon to be used as a favicon displayed in web browser.',
diff --git a/rdiffweb/core/model/_user.py b/rdiffweb/core/model/_user.py
index d2bbcd090..d3dc7da6f 100644
--- a/rdiffweb/core/model/_user.py
+++ b/rdiffweb/core/model/_user.py
@@ -22,11 +22,11 @@ import string
 
 import cherrypy
 from sqlalchemy import Column, Integer, SmallInteger, String, and_, event, inspect, or_
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.hybrid import hybrid_property
-from sqlalchemy.orm import deferred, relationship
+from sqlalchemy.orm import deferred, relationship, validates
 from zxcvbn import zxcvbn
 
 import rdiffweb.tools.db  # noqa
 from rdiffweb.core import authorizedkeys
 from rdiffweb.core.passwd import check_password, hash_password
@@ -72,33 +72,33 @@ class UserObject(Base):
     PATTERN_EMAIL = r"[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$"
     PATTERN_FULLNAME = r"""[^!"#$%&()*+,./:;<=>?@[\]_{|}~]+$"""
     PATTERN_USERNAME = r"[a-zA-Z0-9_.\-]+$"
 
     userid = Column('UserID', Integer, primary_key=True)
-    _username = Column('Username', String, nullable=False, unique=True)
+    username = Column('Username', String, nullable=False, unique=True)
     hash_password = Column('Password', String, nullable=False, default="")
-    _user_root = Column('UserRoot', String, nullable=False, default="")
+    user_root = Column('UserRoot', String, nullable=False, default="")
     _is_admin = deferred(
         Column(
             'IsAdmin',
             SmallInteger,
             nullable=False,
             server_default="0",
             doc="DEPRECATED This column is replaced by 'role'",
         )
     )
-    _email = Column('UserEmail', String, nullable=False, default="")
+    email = Column('UserEmail', String, nullable=False, default="")
     restore_format = deferred(
         Column(
             'RestoreFormat',
             SmallInteger,
             nullable=False,
             server_default="1",
             doc="DEPRECATED This column is not used anymore",
         )
     )
-    _role = Column('role', SmallInteger, nullable=False, server_default=str(USER_ROLE))
+    role = Column('role', SmallInteger, nullable=False, server_default=str(USER_ROLE), default=USER_ROLE)
     fullname = Column('fullname', String, nullable=False, default="")
     mfa = Column('mfa', SmallInteger, nullable=False, default=DISABLED_MFA)
     repo_objs = relationship(
         'RepoObject',
         foreign_keys='UserObject.userid',
@@ -127,11 +127,11 @@ class UserObject(Base):
         else:
             userobj.hash_password = hash_password('admin123')
         userobj.add()
 
     @classmethod
-    def add_user(cls, username, password=None, **attrs):
+    def add_user(cls, username, password=None, role=USER_ROLE, **attrs):
         """
         Used to add a new user with an optional password.
         """
         assert password is None or isinstance(password, str)
         # Check if user already exists.
@@ -141,10 +141,11 @@ class UserObject(Base):
         # Find a database where to add the user
         logger.info("adding new user [%s]", username)
         userobj = UserObject(
             username=username,
             hash_password=hash_password(password) if password else '',
+            role=role,
             **attrs,
         ).add()
         # Raise event
         cherrypy.engine.publish('user_added', userobj)
         # Return user object
@@ -381,55 +382,15 @@ class UserObject(Base):
         self.hash_password = hash_password(password)
 
     def __eq__(self, other):
         return type(self) == type(other) and inspect(self).key == inspect(other).key
 
-    @hybrid_property
-    def username(self):
-        return self._username
-
-    @username.setter
-    def username(self, value):
-        oldvalue = self._username
-        self._username = value
-        if oldvalue != value:
-            cherrypy.engine.publish('user_attr_changed', self, {'username': (oldvalue, value)})
-
-    @hybrid_property
-    def role(self):
-        if self._role is None:
-            return self.USER_ROLE
-        return self._role
-
-    @role.setter
-    def role(self, value):
-        oldvalue = self._role
-        self._role = value
-        if oldvalue != value:
-            cherrypy.engine.publish('user_attr_changed', self, {'role': (oldvalue, value)})
-
-    @hybrid_property
-    def email(self):
-        return self._email
-
-    @email.setter
-    def email(self, value):
-        oldvalue = self._email
-        self._email = value
-        if oldvalue != value:
-            cherrypy.engine.publish('user_attr_changed', self, {'email': (oldvalue, value)})
-
-    @hybrid_property
-    def user_root(self):
-        return self._user_root
-
-    @user_root.setter
-    def user_root(self, value):
-        oldvalue = self._user_root
-        self._user_root = value
-        if oldvalue != value:
-            cherrypy.engine.publish('user_attr_changed', self, {'user_root': (oldvalue, value)})
+    @validates('username')
+    def validates_username(self, key, value):
+        if self.username:
+            raise ValueError('Username cannot be modified.')
+        return value
 
     def validate_access_token(self, token):
         """
         Check if the given token matches.
         """
@@ -458,5 +419,21 @@ def hash_password_set(target, value, oldvalue, initiator):
 def user_after_delete(mapper, connection, target):
     """
     Publish event when user is deleted.
     """
     cherrypy.engine.publish('user_deleted', target.username)
+
+
+@event.listens_for(UserObject, 'after_update')
+def user_attr_changed(mapper, connection, target):
+    changes = {}
+    state = inspect(target)
+    for attr in state.attrs:
+        if attr.key in ['user_root', 'email', 'role', 'mfa']:
+            hist = attr.load_history()
+            if hist.has_changes():
+                changes[attr.key] = (
+                    hist.deleted[0] if len(hist.deleted) >= 1 else None,
+                    hist.added[0] if len(hist.added) >= 1 else None,
+                )
+    if changes:
+        cherrypy.engine.publish('user_attr_changed', target, changes)
diff --git a/rdiffweb/core/model/tests/test_user.py b/rdiffweb/core/model/tests/test_user.py
index b52589588..6d95889bd 100644
--- a/rdiffweb/core/model/tests/test_user.py
+++ b/rdiffweb/core/model/tests/test_user.py
@@ -34,15 +34,10 @@ from rdiffweb.core import authorizedkeys
 from rdiffweb.core.model import DuplicateSSHKeyError, RepoObject, Token, UserObject
 from rdiffweb.core.passwd import check_password
 
 
 class UserObjectTest(rdiffweb.test.WebCase):
-
-    default_config = {
-        'email-send-changed-notification': True,
-    }
-
     def _read_ssh_key(self):
         """Readthe pub key from test packages"""
         filename = pkg_resources.resource_filename('rdiffweb.core.tests', 'test_publickey_ssh_rsa.pub')
         with open(filename, 'r', encoding='utf8') as f:
             return f.readline()
@@ -172,16 +167,20 @@ class UserObjectTest(rdiffweb.test.WebCase):
 
         user.user_root = self.testcases
         user.refresh_repos()
         self.listener.user_attr_changed.assert_called_with(user, {'user_root': ('', self.testcases)})
         self.listener.user_attr_changed.reset_mock()
+        user = UserObject.get_user('larry')
         user.role = UserObject.ADMIN_ROLE
+        user.add()
         self.listener.user_attr_changed.assert_called_with(
             user, {'role': (UserObject.USER_ROLE, UserObject.ADMIN_ROLE)}
         )
         self.listener.user_attr_changed.reset_mock()
+        user = UserObject.get_user('larry')
         user.email = 'larry@gmail.com'
+        user.add()
         self.listener.user_attr_changed.assert_called_with(user, {'email': ('', 'larry@gmail.com')})
         self.listener.user_attr_changed.reset_mock()
 
         self.assertEqual('larry@gmail.com', user.email)
         self.assertEqual(['broker-repo', 'testcases'], sorted([r.name for r in user.repo_objs]))
diff --git a/rdiffweb/core/notification.py b/rdiffweb/core/notification.py
index c22b20e4b..f64103a93 100644
--- a/rdiffweb/core/notification.py
+++ b/rdiffweb/core/notification.py
@@ -76,23 +76,35 @@ class NotificationPlugin(SimplePlugin):
     def user_attr_changed(self, userobj, attrs={}):
         if not self.send_changed:
             return
 
         # Leave if the mail was not changed.
-        if 'email' not in attrs:
-            return
-
-        old_email = attrs['email'][0]
-        if not old_email:
-            logger.info("can't sent mail to user [%s] without an email", userobj.username)
-            return
-
-        # If the email attributes was changed, send a mail notification.
-        body = self.app.templates.compile_template(
-            "email_changed.html", **{"header_name": self.app.cfg.header_name, 'user': userobj}
-        )
-        self.bus.publish('queue_mail', to=old_email, subject=_("Email address changed"), message=body)
+        if 'email' in attrs:
+            old_email = attrs['email'][0]
+            if not old_email:
+                logger.info("can't sent mail to user [%s] without an email", userobj.username)
+                return
+            # If the email attributes was changed, send a mail notification.
+            subject = _("Email address changed")
+            body = self.app.templates.compile_template(
+                "email_changed.html", **{"header_name": self.app.cfg.header_name, 'user': userobj}
+            )
+            self.bus.publish('queue_mail', to=old_email, subject=str(subject), message=body)
+
+        if 'mfa' in attrs:
+            if not userobj.email:
+                logger.info("can't sent mail to user [%s] without an email", userobj.username)
+                return
+            subject = (
+                _("Two-Factor Authentication turned off")
+                if userobj.mfa == UserObject.DISABLED_MFA
+                else _("Two-Factor Authentication turned on")
+            )
+            body = self.app.templates.compile_template(
+                "email_mfa.html", **{"header_name": self.app.cfg.header_name, 'user': userobj}
+            )
+            self.bus.publish('queue_mail', to=userobj.email, subject=str(subject), message=body)
 
     def user_password_changed(self, userobj):
         if not self.send_changed:
             return
 
diff --git a/rdiffweb/core/tests/test_notification.py b/rdiffweb/core/tests/test_notification.py
index a4ecfd0c9..27b54b1c5 100644
--- a/rdiffweb/core/tests/test_notification.py
+++ b/rdiffweb/core/tests/test_notification.py
@@ -116,14 +116,17 @@ class NotificationPluginTest(rdiffweb.test.WebCase):
 
     def test_email_changed(self):
         # Given a user with an email address
         user = UserObject.get_user(self.USERNAME)
         user.email = 'original_email@test.com'
+        user.add()
         self.listener.queue_email.reset_mock()
 
         # When updating the user's email
+        user = UserObject.get_user(self.USERNAME)
         user.email = 'email_changed@test.com'
+        user.add()
 
         # Then a email is queue to notify the user.
         self.listener.queue_email.assert_called_once_with(
             to='original_email@test.com',
             subject='Email address changed',
diff --git a/rdiffweb/templates/email_mfa.html b/rdiffweb/templates/email_mfa.html
index da796644b..cbdff6424 100644
--- a/rdiffweb/templates/email_mfa.html
+++ b/rdiffweb/templates/email_mfa.html
@@ -1,18 +1,16 @@
 <html>
   <head></head>
   <body>
     {% trans username=(user.fullname or user.username) %}Hey {{ username }},{% endtrans %}
     <p>
-      {% trans %}To help us make sure it's really you, here's the verification code you'll need to log in:{% endtrans %}
+      {% if user.mfa %}
+        {% trans %}Your {{ header_name }} Account is now protected with Two-Factor Authentication. When you sign in on a new or untrusted device, you'll need your second factor to verify your identity.{% endtrans %}
+      {% else %}
+        {% trans %}Your {{ header_name }} account is no longer protected with Two-Factor Authentication. You don't need your second factor to sign in.{% endtrans %}
+      {% endif %}
     </p>
     <p>
-      <strong>{{ code }}</strong>
-    </p>
-    <p>
-      {% trans %}If this wasn't you logging in, and you use a password to log in, please reset your password.{% endtrans %}
-    </p>
-    <p>
-      {% trans %}This code will expire in 1 hour. Once the code expires, you will need to request a new verification code by going through the login procedure again.{% endtrans %}
+      {% trans %}You received this email to let you know about important changes to your Google Account and services.{% endtrans %}
     </p>
   </body>
 </html>
diff --git a/rdiffweb/templates/email_verification_code.html b/rdiffweb/templates/email_verification_code.html
new file mode 100644
index 000000000..da796644b
--- /dev/null
+++ b/rdiffweb/templates/email_verification_code.html
@@ -0,0 +1,18 @@
+<html>
+  <head></head>
+  <body>
+    {% trans username=(user.fullname or user.username) %}Hey {{ username }},{% endtrans %}
+    <p>
+      {% trans %}To help us make sure it's really you, here's the verification code you'll need to log in:{% endtrans %}
+    </p>
+    <p>
+      <strong>{{ code }}</strong>
+    </p>
+    <p>
+      {% trans %}If this wasn't you logging in, and you use a password to log in, please reset your password.{% endtrans %}
+    </p>
+    <p>
+      {% trans %}This code will expire in 1 hour. Once the code expires, you will need to request a new verification code by going through the login procedure again.{% endtrans %}
+    </p>
+  </body>
+</html>
diff --git a/tox.ini b/tox.ini
index 7a2ae1960..c64608a36 100644
--- a/tox.ini
+++ b/tox.ini
@@ -75,11 +75,11 @@ commands = sphinx-build -W -b html -d {envtmpdir}/doctrees doc {envtmpdir}/html
 deps = black
 commands = black --check --diff setup.py rdiffweb
 skip_install = true
 
 [testenv:djlint]
-deps = djlint==1.12.1
+deps = djlint==1.19.2
 allowlist_externals = sh
 commands = sh -c 'djlint --check rdiffweb/templates/*.html  rdiffweb/templates/**/*.html'
 skip_install = true
 
 [testenv:flake8]

Vulnerable code:         else:
            code = cherrypy.tools.auth_mfa.generate_code()
            body = self.app.templates.compile_template(
                "email_mfa.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code}
            )
            cherrypy.engine.publish('queue_mail', to=userobj.email, subject=_("Your verification code"), message=body)
            flash(_("A new verification code has been sent to your email."))
            "email_mfa.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code}
from unittest.mock import MagicMock
        self.listener = MagicMock()
        cherrypy.engine.subscribe('queue_mail', self.listener.queue_email, priority=50)
        try:
            self.getPage("/mfa/")
            self.assertStatus(200)
            self.assertInBody("A new verification code has been sent to your email.")
            # Extract code from email between <strong> and </strong>
            self.listener.queue_email.assert_called_once()
            message = self.listener.queue_email.call_args[1]['message']
            code = message.split('<strong>', 1)[1].split('</strong>')[0]
            # Login to MFA
            self.getPage("/mfa/", method='POST', body={'code': code, 'submit': '1'})
            self.assertStatus(303)
        finally:
            cherrypy.engine.unsubscribe('queue_mail', self.listener.queue_email)
        # Register an email listeer to capture email send
        self.listener = MagicMock()
        cherrypy.engine.subscribe('queue_mail', self.listener.queue_email, priority=50)
        try:
            self.getPage("/prefs/mfa", method='POST', body={action: '1'})
            self.assertStatus(200)
            self.assertInBody("A new verification code has been sent to your email.")
            # Extract code from email between <strong> and </strong>
            self.listener.queue_email.assert_called_once()
            message = self.listener.queue_email.call_args[1]['message']
            return message.split('<strong>', 1)[1].split('</strong>')[0]
        finally:
            cherrypy.engine.unsubscribe('queue_mail', self.listener.queue_email)
            ('enable_mfa', UserObject.DISABLED_MFA, UserObject.ENABLED_MFA),
            ('disable_mfa', UserObject.ENABLED_MFA, UserObject.DISABLED_MFA),
    def test_with_valid_code(self, action, initial_mfa, expected_mfa):
        # Then no email get sent
        default=False,
from sqlalchemy.orm import deferred, relationship
    _username = Column('Username', String, nullable=False, unique=True)
    _user_root = Column('UserRoot', String, nullable=False, default="")
    _email = Column('UserEmail', String, nullable=False, default="")
    _role = Column('role', SmallInteger, nullable=False, server_default=str(USER_ROLE))
    def add_user(cls, username, password=None, **attrs):
    @hybrid_property
    def username(self):
        return self._username

    @username.setter
    def username(self, value):
        oldvalue = self._username
        self._username = value
        if oldvalue != value:
            cherrypy.engine.publish('user_attr_changed', self, {'username': (oldvalue, value)})

    @hybrid_property
    def role(self):
        if self._role is None:
            return self.USER_ROLE
        return self._role

    @role.setter
    def role(self, value):
        oldvalue = self._role
        self._role = value
        if oldvalue != value:
            cherrypy.engine.publish('user_attr_changed', self, {'role': (oldvalue, value)})

    @hybrid_property
    def email(self):
        return self._email

    @email.setter
    def email(self, value):
        oldvalue = self._email
        self._email = value
        if oldvalue != value:
            cherrypy.engine.publish('user_attr_changed', self, {'email': (oldvalue, value)})

    @hybrid_property
    def user_root(self):
        return self._user_root

    @user_root.setter
    def user_root(self, value):
        oldvalue = self._user_root
        self._user_root = value
        if oldvalue != value:
            cherrypy.engine.publish('user_attr_changed', self, {'user_root': (oldvalue, value)})

    default_config = {
        'email-send-changed-notification': True,
    }

        if 'email' not in attrs:
            return

        old_email = attrs['email'][0]
        if not old_email:
            logger.info("can't sent mail to user [%s] without an email", userobj.username)
            return

        # If the email attributes was changed, send a mail notification.
        body = self.app.templates.compile_template(
            "email_changed.html", **{"header_name": self.app.cfg.header_name, 'user': userobj}
        )
        self.bus.publish('queue_mail', to=old_email, subject=_("Email address changed"), message=body)
      {% trans %}To help us make sure it's really you, here's the verification code you'll need to log in:{% endtrans %}
      <strong>{{ code }}</strong>
    </p>
    <p>
      {% trans %}If this wasn't you logging in, and you use a password to log in, please reset your password.{% endtrans %}
    </p>
    <p>
      {% trans %}This code will expire in 1 hour. Once the code expires, you will need to request a new verification code by going through the login procedure again.{% endtrans %}
deps = djlint==1.12.1
Fixed code: * Send email notification when enabling or disabling MFA [CVE-2022-3363](https://nvd.nist.gov/vuln/detail/CVE-2022-3363)
            return
        code = cherrypy.tools.auth_mfa.generate_code()
        body = self.app.templates.compile_template(
            "email_verification_code.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code}
        )
        cherrypy.engine.publish('queue_mail', to=userobj.email, subject=_("Your verification code"), message=body)
        flash(_("A new verification code has been sent to your email."))
            "email_verification_code.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code}
from unittest.mock import ANY, MagicMock
        # Register a listener on email
        self.listener = MagicMock()
        cherrypy.engine.subscribe('queue_mail', self.listener.queue_email, priority=50)

    def tearDown(self):
        cherrypy.engine.unsubscribe('queue_mail', self.listener.queue_email)
        return super().tearDown()
        # Reset mock.
        self.listener.queue_email.reset_mock()
        # Leave to disable mfa
        self.getPage("/mfa/")
        self.assertStatus(200)
        self.assertInBody("A new verification code has been sent to your email.")
        # Extract code from email between <strong> and </strong>
        self.listener.queue_email.assert_called_once()
        message = self.listener.queue_email.call_args[1]['message']
        code = message.split('<strong>', 1)[1].split('</strong>')[0]
        # Login to MFA
        self.getPage("/mfa/", method='POST', body={'code': code, 'submit': '1'})
        self.assertStatus(303)
        # Clear mock.
        self.listener.queue_email.reset_mock()
        self.getPage("/prefs/mfa", method='POST', body={action: '1'})
        self.assertStatus(200)
        self.assertInBody("A new verification code has been sent to your email.")
        # Extract code from email between <strong> and </strong>
        self.listener.queue_email.assert_called_once()
        message = self.listener.queue_email.call_args[1]['message']
        self.listener.queue_email.reset_mock()
        return message.split('<strong>', 1)[1].split('</strong>')[0]
            ('enable_mfa', UserObject.DISABLED_MFA, UserObject.ENABLED_MFA, "Two-Factor Authentication turned on"),
            ('disable_mfa', UserObject.ENABLED_MFA, UserObject.DISABLED_MFA, "Two-Factor Authentication turned off"),
    def test_with_valid_code(self, action, initial_mfa, expected_mfa, expected_subject):
        # Then no verification code get sent
        # Then an email confirmation get send
        self.listener.queue_email.assert_called_once_with(to=ANY, subject=expected_subject, message=ANY)
        default=True,
from sqlalchemy.orm import deferred, relationship, validates
    username = Column('Username', String, nullable=False, unique=True)
    user_root = Column('UserRoot', String, nullable=False, default="")
    email = Column('UserEmail', String, nullable=False, default="")
    role = Column('role', SmallInteger, nullable=False, server_default=str(USER_ROLE), default=USER_ROLE)
    def add_user(cls, username, password=None, role=USER_ROLE, **attrs):
            role=role,
    @validates('username')
    def validates_username(self, key, value):
        if self.username:
            raise ValueError('Username cannot be modified.')
        return value


@event.listens_for(UserObject, 'after_update')
def user_attr_changed(mapper, connection, target):
    changes = {}
    state = inspect(target)
    for attr in state.attrs:
        if attr.key in ['user_root', 'email', 'role', 'mfa']:
            hist = attr.load_history()
            if hist.has_changes():
                changes[attr.key] = (
                    hist.deleted[0] if len(hist.deleted) >= 1 else None,
                    hist.added[0] if len(hist.added) >= 1 else None,
                )
    if changes:
        cherrypy.engine.publish('user_attr_changed', target, changes)
        user = UserObject.get_user('larry')
        user.add()
        user = UserObject.get_user('larry')
        user.add()
        if 'email' in attrs:
            old_email = attrs['email'][0]
            if not old_email:
                logger.info("can't sent mail to user [%s] without an email", userobj.username)
                return
            # If the email attributes was changed, send a mail notification.
            subject = _("Email address changed")
            body = self.app.templates.compile_template(
                "email_changed.html", **{"header_name": self.app.cfg.header_name, 'user': userobj}
            )
            self.bus.publish('queue_mail', to=old_email, subject=str(subject), message=body)

        if 'mfa' in attrs:
            if not userobj.email:
                logger.info("can't sent mail to user [%s] without an email", userobj.username)
                return
            subject = (
                _("Two-Factor Authentication turned off")
                if userobj.mfa == UserObject.DISABLED_MFA
                else _("Two-Factor Authentication turned on")
            )
            body = self.app.templates.compile_template(
                "email_mfa.html", **{"header_name": self.app.cfg.header_name, 'user': userobj}
            )
            self.bus.publish('queue_mail', to=userobj.email, subject=str(subject), message=body)
        user.add()
        user = UserObject.get_user(self.USERNAME)
        user.add()
      {% if user.mfa %}
        {% trans %}Your {{ header_name }} Account is now protected with Two-Factor Authentication. When you sign in on a new or untrusted device, you'll need your second factor to verify your identity.{% endtrans %}
      {% else %}
        {% trans %}Your {{ header_name }} account is no longer protected with Two-Factor Authentication. You don't need your second factor to sign in.{% endtrans %}
      {% endif %}
      {% trans %}You received this email to let you know about important changes to your Google Account and services.{% endtrans %}
<html>
  <head></head>
  <body>
    {% trans username=(user.fullname or user.username) %}Hey {{ username }},{% endtrans %}
    <p>
      {% trans %}To help us make sure it's really you, here's the verification code you'll need to log in:{% endtrans %}
    </p>
    <p>
      <strong>{{ code }}</strong>
    </p>
    <p>
      {% trans %}If this wasn't you logging in, and you use a password to log in, please reset your password.{% endtrans %}
    </p>
    <p>
      {% trans %}This code will expire in 1 hour. Once the code expires, you will need to request a new verification code by going through the login procedure again.{% endtrans %}
    </p>
  </body>
</html>
deps = djlint==1.19.2