Authentication Bypass by Primary Weakness in ikus060/rdiffweb
Description
Authentication Bypass by Primary Weakness in GitHub repository ikus060/rdiffweb prior to 2.5.5.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Authentication bypass via case-insensitive username matching in Rdiffweb prior to 2.5.5 allows attackers to impersonate other users.
Vulnerability
Description CVE-2022-4722 is an authentication bypass vulnerability in Rdiffweb, a web-based backup management application, affecting versions prior to 2.5.5. The root cause is a primary weakness in the authentication logic: the software's username handling was not case‑insensitive, allowing an attacker to log in using a case‑variant of an existing username (e.g., 'Admin' instead of 'admin'). This flaw is classified as CWE-305 (Authentication Bypass by Primary Weakness) [1][3].
Exploitation
To exploit this vulnerability, an attacker only needs network access to the Rdiffweb instance and knowledge of a valid username. No prior authentication is required. By providing a case‑permuted version of the target username, the system’s authentication mechanism may match the incorrect case variant, granting access to the attacker without verifying the correct credentials. The attack complexity is low, and no special privileges are needed [1][3].
Impact
Successful exploitation allows an attacker to bypass authentication entirely and gain unauthorized access to another user's account, including administrative accounts. Once authenticated, the attacker can view, modify, or delete backup data, manage repository configurations, and perform other privileged actions, potentially compromising the confidentiality and integrity of all stored backups [1][2].
Mitigation
The vulnerability has been addressed in Rdiffweb version 2.5.5. The fix, implemented in commit d1aaa96, introduced case‑insensitive username comparison by adding a user_username_index migration that normalizes username storage [2]. Users are strongly advised to upgrade to Rdiffweb 2.5.5 or later. No known workarounds are documented, but restricting network access to the Rdiffweb interface can reduce the attack surface until the update is applied [3][4].
AI Insight generated on May 20, 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.5 | 2.5.5 |
Affected products
2- ikus060/ikus060/rdiffwebv5Range: unspecified
Patches
1d1aaa96b665aMake username case-insensitive
8 files changed · +119 −48
rdiffweb/controller/tests/test_page_login.py+9 −0 modified@@ -64,6 +64,15 @@ def test_login_success(self): self.assertEqual('admin', session.get(SESSION_KEY)) self.assertIsNotNone(session.get(LOGIN_TIME)) + def test_login_case_insensitive(self): + # When authenticating with valid credentials with all uppercase username + self.getPage('/login/', method='POST', body={'login': self.USERNAME.upper(), 'password': self.PASSWORD}) + # Then a new session_id is generated + self.assertStatus('303 See Other') + self.assertHeaderItemValue('Location', self.baseurl + '/') + self.getPage('/') + self.assertStatus(200) + def test_cookie_http_only(self): # Given a request made to rdiffweb # When receiving the response
rdiffweb/controller/tests/test_page_prefs_general.py+5 −5 modified@@ -85,8 +85,8 @@ def test_change_username_noop(self): self.assertStatus(303) self.getPage(self.PREFS) self.assertInBody("Profile updated successfully.") - # Then database is updated with fullname - user = UserObject.query.filter(UserObject.username == self.USERNAME).first() + # Then database is not updated with new username. + user = UserObject.get_user(self.USERNAME) self.assertIsNotNone(user) self.assertEqual("test@test.com", user.email) @@ -112,7 +112,7 @@ def test_change_fullname(self, new_fullname, expected_valid): self.assertInBody("Profile updated successfully.") # Then database is updated with fullname self.assertInBody(new_fullname) - user = UserObject.query.filter(UserObject.username == self.USERNAME).first() + user = UserObject.get_user(self.USERNAME) self.assertEqual(new_fullname, user.fullname) else: self.assertStatus(200) @@ -125,7 +125,7 @@ def test_change_fullname_method_get(self): # Then nothing happen self.assertStatus(200) self.assertNotInBody("Profile updated successfully.") - user = UserObject.query.filter(UserObject.username == self.USERNAME).first() + user = UserObject.get_user(self.USERNAME) self.assertEqual("", user.fullname) def test_change_fullname_too_long(self): @@ -137,7 +137,7 @@ def test_change_fullname_too_long(self): self.assertNotInBody("Profile updated successfully.") self.assertInBody("Fullname too long.") # Then database is not updated - user = UserObject.query.filter(UserObject.username == self.USERNAME).first() + user = UserObject.get_user(self.USERNAME) self.assertEqual("", user.fullname) def test_change_email(self):
rdiffweb/core/login.py+2 −2 modified@@ -50,7 +50,7 @@ def authenticate(self, username, password): """ Only verify the user's credentials using the database store. """ - user = UserObject.query.filter_by(username=username).first() + user = UserObject.get_user(username) if user and user.validate_password(password): return username, {} return False @@ -69,7 +69,7 @@ def login(self, username, password): fullname = extra_attrs.get('_fullname', None) email = extra_attrs.get('_email', None) # When enabled, create missing userobj in database. - userobj = UserObject.query.filter_by(username=username).first() + userobj = UserObject.get_user(username) if userobj is None and self.add_missing_user: try: # At this point, we need to create a new user in database.
rdiffweb/core/model/__init__.py+68 −32 modified@@ -15,74 +15,91 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import logging +import sys + import cherrypy from sqlalchemy import event +from sqlalchemy.exc import IntegrityError from ._repo import RepoObject # noqa from ._session import DbSession, SessionObject # noqa from ._sshkey import SshKey # noqa from ._token import Token # noqa -from ._user import DuplicateSSHKeyError, UserObject # noqa +from ._user import DuplicateSSHKeyError, UserObject, user_username_index # noqa Base = cherrypy.tools.db.get_base() +logger = logging.getLogger(__name__) + + +def _column_add(connection, column): + if _column_exists(connection, column): + return + table_name = column.table.fullname + column_name = column.name + column_type = column.type.compile(connection.engine.dialect) + connection.engine.execute('ALTER TABLE %s ADD COLUMN %s %s' % (table_name, column_name, column_type)) + + +def _column_exists(connection, column): + table_name = column.table.fullname + column_name = column.name + if 'SQLite' in connection.engine.dialect.__class__.__name__: + sql = "SELECT COUNT(*) FROM pragma_table_info('%s') WHERE LOWER(name)=LOWER('%s')" % ( + table_name, + column_name, + ) + else: + sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='%s' and column_name='%s'" % ( + table_name, + column_name, + ) + data = connection.engine.execute(sql).first() + return data[0] >= 1 + + +def _index_exists(connection, index_name): + if 'SQLite' in connection.engine.dialect.__class__.__name__: + sql = "SELECT name FROM sqlite_master WHERE type = 'index' AND name = '%s';" % (index_name) + else: + sql = "SELECT * FROM pg_indexes WHERE indexname = '%s'" % (index_name) + return connection.engine.execute(sql).first() is not None + @event.listens_for(Base.metadata, 'after_create') def db_after_create(target, connection, **kw): """ Called on database creation to update database schema. """ - def exists(column): - table_name = column.table.fullname - column_name = column.name - if 'SQLite' in connection.engine.dialect.__class__.__name__: - sql = "SELECT COUNT(*) FROM pragma_table_info('%s') WHERE LOWER(name)=LOWER('%s')" % ( - table_name, - column_name, - ) - else: - sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name='%s' and column_name='%s'" % ( - table_name, - column_name, - ) - data = connection.engine.execute(sql).first() - return data[0] >= 1 - - def add_column(column): - if exists(column): - return - table_name = column.table.fullname - column_name = column.name - column_type = column.type.compile(connection.engine.dialect) - connection.engine.execute('ALTER TABLE %s ADD COLUMN %s %s' % (table_name, column_name, column_type)) - if getattr(connection, '_transaction', None): connection._transaction.commit() # Add repo's Encoding - add_column(RepoObject.__table__.c.Encoding) - add_column(RepoObject.__table__.c.keepdays) + _column_add(connection, RepoObject.__table__.c.Encoding) + _column_add(connection, RepoObject.__table__.c.keepdays) # Create column for roles using "isadmin" column. Keep the # original column in case we need to revert to previous version. - if not exists(UserObject.__table__.c.role): - add_column(UserObject.__table__.c.role) + if not _column_exists(connection, UserObject.__table__.c.role): + _column_add(connection, UserObject.__table__.c.role) UserObject.query.filter(UserObject._is_admin == 1).update({UserObject.role: UserObject.ADMIN_ROLE}) # Add user's fullname column - add_column(UserObject.__table__.c.fullname) + _column_add(connection, UserObject.__table__.c.fullname) # Add user's mfa column - add_column(UserObject.__table__.c.mfa) + _column_add(connection, UserObject.__table__.c.mfa) # Re-create session table if Number column is missing - if not exists(SessionObject.__table__.c.Number): + if not _column_exists(connection, SessionObject.__table__.c.Number): SessionObject.__table__.drop() SessionObject.__table__.create() if getattr(connection, '_transaction', None): connection._transaction.commit() + # Remove preceding and leading slash (/) generated by previous # versions. Also rename '.' to '' result = RepoObject.query.all() @@ -101,3 +118,22 @@ def add_column(column): row.delete() else: prev_repo = (row.userid, row.repopath) + + # Fix username case insensitive unique + if not _index_exists(connection, 'user_username_index'): + duplicate_users = ( + UserObject.query.with_entities(func.lower(UserObject.username)) + .group_by(func.lower(UserObject.username)) + .having(func.count(UserObject.username) > 1) + ).all() + try: + user_username_index.create() + except IntegrityError: + msg = ( + 'Failure to upgrade your database to make Username case insensitive. ' + 'You must downgrade and deleted duplicate Username. ' + '%s' % '\n'.join([str(k) for k in duplicate_users]), + ) + logger.error(msg) + print(msg, file=sys.stderr) + raise SystemExit(12)
rdiffweb/core/model/tests/test_user.py+17 −0 modified@@ -103,6 +103,16 @@ def test_add_user_with_duplicate(self): # Check if listener called self.listener.user_added.assert_not_called() + def test_add_user_with_duplicate_caseinsensitive(self): + """Add user to database.""" + user = UserObject.add_user('denise') + user.commit() + self.listener.user_added.reset_mock() + with self.assertRaises(ValueError): + UserObject.add_user('dEnIse') + # Check if listener called + self.listener.user_added.assert_not_called() + def test_add_user_with_password(self): """Add user to database with password.""" userobj = UserObject.add_user('jo', 'password') @@ -158,6 +168,13 @@ def test_get_user(self): self.assertEqual('testcases', obj.repo_objs[1].name) self.assertEqual(3, obj.repo_objs[1].maxage) + def test_get_user_case_insensitive(self): + userobj1 = UserObject.get_user(self.USERNAME) + userobj2 = UserObject.get_user(self.USERNAME.lower()) + userobj3 = UserObject.get_user(self.USERNAME.upper()) + self.assertEqual(userobj1, userobj2) + self.assertEqual(userobj2, userobj3) + def test_get_user_with_invalid_user(self): self.assertIsNone(UserObject.get_user('invalid'))
rdiffweb/core/model/_user.py+8 −4 modified@@ -20,7 +20,7 @@ import string import cherrypy -from sqlalchemy import Column, Integer, SmallInteger, String, and_, event, inspect, or_ +from sqlalchemy import Column, Index, Integer, SmallInteger, String, and_, event, func, inspect, or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import deferred, relationship, validates @@ -74,7 +74,7 @@ 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) hash_password = Column('Password', String, nullable=False, default="") user_root = Column('UserRoot', String, nullable=False, default="") _is_admin = deferred( @@ -110,8 +110,8 @@ class UserObject(Base): @classmethod def get_user(cls, user): - """Return a user object.""" - return UserObject.query.filter(UserObject.username == user).first() + """Return a user object with username case-insensitive""" + return UserObject.query.filter(func.lower(UserObject.username) == user.lower()).first() @classmethod def create_admin_user(cls, default_username, default_password): @@ -413,6 +413,10 @@ def validate_password(self, password): return check_password(password, self.hash_password) +# Username should be case insensitive +user_username_index = Index('user_username_index', func.lower(UserObject.username), unique=True) + + @event.listens_for(UserObject.hash_password, "set") def hash_password_set(target, value, oldvalue, initiator): if value and value != oldvalue:
rdiffweb/core/tests/test_rdw_templating.py+1 −1 modified@@ -110,7 +110,7 @@ def test_list_parents_with_root_subdir(self): class UrlForTest(WebCase): @property def repo_obj(self): - user = UserObject.query.filter(UserObject.username == 'admin').first() + user = UserObject.get_user('admin') return RepoObject.query.filter(RepoObject.user == user, RepoObject.repopath == self.REPO).first() def test_url_for_absolute_path(self):
README.md+9 −4 modified@@ -26,7 +26,7 @@ by [rdiff-backup](https://rdiff-backup.net/). The purpose of this application is to ease the management of backups and quickly restore your data with a rich and powerful web interface. -Rdiffweb is written in Python and is released as open source project under the +Rdiffweb is written in Python and is released as open source project under the GNU GENERAL PUBLIC LICENSE (GPL). All source code and documentation are Copyright Rdiffweb contributors. @@ -36,7 +36,7 @@ since November 2014. The Rdiffweb source code is hosted on [Gitlab](https://gitlab.com/ikus-soft/rdiffweb) and mirrored to [Github](https://github.com/ikus060/rdiffweb). -The Rdiffweb website is https://rdiffweb.org/. +The Rdiffweb website is <https://rdiffweb.org/>. ## Features @@ -100,7 +100,7 @@ Rdiffweb users should use the [Rdiffweb mailing list](https://groups.google.com/ ### Bug Reports -Bug reports should be reported on the Rdiffweb Gitlab at https://gitlab.com/ikus-soft/rdiffweb/-/issues +Bug reports should be reported on the Rdiffweb Gitlab at <https://gitlab.com/ikus-soft/rdiffweb/-/issues> ### Professional support @@ -114,6 +114,11 @@ Professional support for Rdiffweb is available by contacting [IKUS Soft](https:/ * Ensure Gmail and other mail client doesn't create hyperlink automatically for any nodification sent by Rdiffweb to avoid phishing - credit to [Nehal Pillai](https://www.linkedin.com/in/nehal-pillai-02a854172) * Sent email notification to user when a new SSH Key get added - credit to [Nehal Pillai](https://www.linkedin.com/in/nehal-pillai-02a854172) * Ratelimit "Resend code to my email" in Two-Factor Authentication view - credit to [Nehal Pillai](https://www.linkedin.com/in/nehal-pillai-02a854172) +* Username are not case-insensitive - credits to [raiders0786](https://www.linkedin.com/in/chirag-agrawal-770488144/) + +Breaking changes: + +* Username with different cases (e.g.: admin vs Ammin) are not supported. If your database contains such username make sure to remove them before upgrading otherwise Rdiffweb will not start. ## 2.5.4 (2022-12-19) @@ -378,7 +383,7 @@ Maintenance release to fix minor issues ## 2.1.0 (2021-01-15) -* Debian package: Remove dh-systemd from Debian build dependencies (https://bugs.debian.org/871312we) +* Debian package: Remove dh-systemd from Debian build dependencies (<https://bugs.debian.org/871312we>) * Improve Quota management: * `QuotaSetCmd`, `QuotaGetCmd` and `QuotaUsedCmd` options could be used to customize how to set the quota for your environment. * Display user's quota in User View
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-wf33-6x33-wcf9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-4722ghsaADVISORY
- github.com/ikus060/rdiffweb/commit/d1aaa96b665a39fba9e98d6054a9de511ba0a837ghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/rdiffweb/PYSEC-2022-43008.yamlghsaWEB
- huntr.dev/bounties/c62126dc-d9a6-4d3e-988d-967031876c58ghsaWEB
News mentions
0No linked articles in our index yet.