Business Logic Errors in ikus060/rdiffweb
Description
Business Logic Errors in GitHub repository ikus060/rdiffweb prior to 2.5.0a7.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
In rdiffweb prior to 2.5.0a7, a business logic error allows an attacker to enable or disable multi-factor authentication (MFA) without triggering an email notification to the user, potentially leading to account takeover.
Vulnerability
Overview
CVE-2022-3363 is a business logic error in the rdiffweb backup management application, affecting versions prior to 2.5.0a7. The flaw resides in the UserObject model, where changes to multi-factor authentication (MFA) settings—such as enabling or disabling MFA—do not trigger an email notification to the affected user. This omission means that a user is not alerted when their MFA configuration is altered, which is a critical security control for detecting unauthorized changes [2][4].
Exploitation
Conditions
To exploit this vulnerability, an attacker must first gain access to a legitimate user's account, for example through credential theft, session hijacking, or other means. Once authenticated, the attacker can modify the MFA settings of that account without any notification being sent to the user. The attack requires no special privileges beyond the compromised user's credentials, and the lack of notification allows the change to go unnoticed [1][2].
Impact
If an attacker disables MFA, they can bypass the second factor of authentication, effectively taking over the account. Conversely, if the attacker enables their own MFA, they can lock the legitimate user out of their account. In either case, the attacker gains persistent unauthorized access to the user's backup data and administrative functions, potentially leading to data theft, ransomware, or further compromise of the infrastructure [4].
Mitigation
The issue was addressed in rdiffweb version 2.5.0a7, which introduced email notifications for MFA changes. Users are strongly advised to upgrade to this version or later. No workarounds are documented; the only reliable mitigation is to apply the patch. The vulnerability is listed in the PySec advisory database, underscoring its severity [2][4].
- GitHub - ikus060/rdiffweb: A simplified backup management software for quick access to your archives through an efficient web interface.
- Send email notification when enabling or disabling MFA · ikus060/rdiffweb@c27c46b
- advisory-database/vulns/rdiffweb/PYSEC-2022-42978.yaml at main · pypa/advisory-database
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
rdiffwebPyPI | < 2.5.0a7 | 2.5.0a7 |
Affected products
2- ikus060/ikus060/rdiffwebv5Range: unspecified
Patches
1c27c46bac656Send email notification when enabling or disabling MFA
12 files changed · +132 −120
rdiffweb/controller/page_mfa.py+7 −7 modified@@ -105,10 +105,10 @@ def send_code(self): "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."))
rdiffweb/controller/page_pref_mfa.py+1 −1 modified@@ -126,7 +126,7 @@ def send_code(self): 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."))
rdiffweb/controller/tests/test_page_prefs_mfa.py+37 −33 modified@@ -15,7 +15,7 @@ # 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 @@ -34,47 +34,49 @@ def setUp(self): 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 @@ -84,11 +86,11 @@ def test_get(self): @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 @@ -99,8 +101,10 @@ def test_with_valid_code(self, action, initial_mfa, expected_mfa): 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)
rdiffweb/core/config.py+1 −1 modified@@ -159,7 +159,7 @@ def get_parser(): '--emailsendchangednotification', help='True to send notification when sensitive information get change in user profile.', action='store_true', - default=False, + default=True, ) parser.add_argument(
rdiffweb/core/model/tests/test_user.py+4 −5 modified@@ -36,11 +36,6 @@ 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') @@ -174,12 +169,16 @@ def test_get_set(self): 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()
rdiffweb/core/model/_user.py+28 −51 modified@@ -24,7 +24,7 @@ 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 @@ -74,9 +74,9 @@ class UserObject(Base): 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', @@ -86,7 +86,7 @@ class UserObject(Base): 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', @@ -96,7 +96,7 @@ class UserObject(Base): 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( @@ -129,7 +129,7 @@ def create_admin_user(cls, default_username, default_password): 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. """ @@ -143,6 +143,7 @@ def add_user(cls, username, password=None, **attrs): userobj = UserObject( username=username, hash_password=hash_password(password) if password else '', + role=role, **attrs, ).add() # Raise event @@ -383,51 +384,11 @@ def set_password(self, 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): """ @@ -460,3 +421,19 @@ 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)
rdiffweb/core/notification.py+25 −13 modified@@ -78,19 +78,31 @@ def user_attr_changed(self, userobj, attrs={}): 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:
rdiffweb/core/tests/test_notification.py+3 −0 modified@@ -118,10 +118,13 @@ 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(
rdiffweb/templates/email_mfa.html+6 −8 modified@@ -3,16 +3,14 @@ <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>
rdiffweb/templates/email_verification_code.html+18 −0 added@@ -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>
README.md+1 −0 modified@@ -138,6 +138,7 @@ This next release focus on two-factor-authentication as a measure to increase se * 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:
tox.ini+1 −1 modified@@ -77,7 +77,7 @@ 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
Vulnerability mechanics
Generated 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-jw36-mrvg-j5fxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-3363ghsaADVISORY
- github.com/ikus060/rdiffweb/commit/c27c46bac656b1da74f28eac1b52dfa5df76e6f2ghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/rdiffweb/PYSEC-2022-42978.yamlghsaWEB
- huntr.dev/bounties/b8a40ba6-2452-4abe-a80a-2d065ee8891eghsaWEB
News mentions
0No linked articles in our index yet.