CVE-2022-3232 (published 2022-09-17T20:15Z)

Cross-Site Request Forgery (CSRF) in GitHub repository ikus060/rdiffweb prior to 2.4.5.

CVSS v3 base score: 4.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N)

Weakness information:
cwe_id: CWE-352
cwe_name: Cross-Site Request Forgery (CSRF)
description: The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
extended_description: When a web server is designed to receive a request from a client without any mechanism for verifying that it was intentionally sent, then it might be possible for an attacker to trick a client into making an unintentional request to the web server which will be treated as an authentic request. This can be done via a URL, image load, XMLHttpRequest, etc. and can result in exposure of data or unintended code execution.
         

Fix information:
repo_url: https://github.com/ikus060/rdiffweb
commit_details: repo_url: https://github.com/ikus060/rdiffweb
author: Patrik Dufresne
author_date: 2022-09-16 11:38:46-04:00
msg: Mitigate CSRF on repository deletion and user deletion #214 #215
repository: repo_url: https://github.com/ikus060/rdiffweb
repo_name: ikus060/rdiffweb
description: A simplified backup management software for quick access to your archives through an efficient web interface.
repo_language: Python
owner: ikus060
file_changes: filename: README.md
change_type: ModificationType.MODIFY
diff: @@ -107,6 +107,12 @@ Professional support for Rdiffweb is available by contacting [IKUS Soft](https:/
 
 # Changelog
 
+## 2.4.5 (2002-09-16)
+
+This releases include a security fix. If you are using an earlier version, you should upgrade to this release immediately.
+
+* Mitigate CSRF on repository deletion and user deletion [CVE-2022-3232](https://nvd.nist.gov/vuln/detail/CVE-2022-3232) #214 #215
+
 ## 2.4.4 (2002-09-15)
 
 This releases include a security fix. If you are using an earlier version, you should upgrade to this release immediately.
@@ -117,7 +123,7 @@ This releases include a security fix. If you are using an earlier version, you s
 
 This releases include a security fix. If you are using an earlier version, you should upgrade to this release immediately.
 
-* Mitigate CSRF in profile's SSH Keys #212
+* Mitigate CSRF in profile's SSH Keys [CVE-2022-3221](https://nvd.nist.gov/vuln/detail/CVE-2022-3221) #212
 
 ## 2.4.2 (2022-09-12)
 

programming_language: Markdown
filename: page_admin.py
change_type: ModificationType.MODIFY
diff: @@ -190,15 +190,13 @@ class UserForm(CherryForm):
         _('Quota Used'), validators=[validators.optional()], description=_("Disk spaces (in bytes) used by this user.")
     )
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.password.validators += [
-            validators.length(
-                min=self.app.cfg.password_min_length,
-                max=self.app.cfg.password_max_length,
-                message=_('Password must have between %(min)d and %(max)d characters.'),
-            )
-        ]
+    def validate_password(self, field):
+        validator = validators.length(
+            min=self.app.cfg.password_min_length,
+            max=self.app.cfg.password_max_length,
+            message=_('Password must have between %(min)d and %(max)d characters.'),
+        )
+        validator(self, field)
 
     @property
     def app(self):
@@ -340,7 +338,9 @@ class AdminPage(Controller):
             else:
                 flash(_("Cannot edit user `%s`: user doesn't exists") % username, level='error')
         elif action == 'delete':
-            self._delete_user(action, DeleteUserForm())
+            form = DeleteUserForm()
+            if form.validate_on_submit():
+                self._delete_user(action, form)
 
         params = {
             "add_form": UserForm(formdata=None),

programming_language: Python
filename: page_delete.py
change_type: ModificationType.MODIFY
diff: @@ -22,24 +22,29 @@ Created on Apr. 5, 2021
 # Define the logger
 
 import logging
+import os
 
 import cherrypy
-from wtforms import validators
 from wtforms.fields.core import StringField
+from wtforms.validators import DataRequired, ValidationError
 
 from rdiffweb.controller import Controller
 from rdiffweb.controller.cherrypy_wtf import CherryForm
 from rdiffweb.controller.dispatch import poppath
 from rdiffweb.controller.filter_authorization import is_maintainer
 from rdiffweb.core.librdiff import AccessDeniedError, DoesNotExistError
+from rdiffweb.core.rdw_templating import url_for
 from rdiffweb.tools.i18n import ugettext as _
 
 _logger = logging.getLogger(__name__)
 
 
 class DeleteRepoForm(CherryForm):
-    confirm = StringField(_('Confirmation'), validators=[validators.data_required()])
-    redirect = StringField(default='/')
+    confirm = StringField(_('Confirmation'), validators=[DataRequired()])
+
+    def validate_confirm(self, field):
+        if self.confirm.data != self.expected_confirm:
+            raise ValidationError(_('Invalid value, must be: %s') % self.expected_confirm)
 
 
 @poppath()
@@ -61,15 +66,17 @@ class DeletePage(Controller):
 
         # validate form
         form = DeleteRepoForm()
-        if not form.validate():
-            raise cherrypy.HTTPError(400, form.error_message)
-
-        # Validate the name
-        if form.confirm.data != path_obj.display_name:
-            _logger.info("do not delete repo, bad confirmation %r != %r", form.confirm.data, path_obj.display_name)
-            raise cherrypy.HTTPError(400, 'bad confirmation')
-
-        # Delete repository in background using a schedule task.
-        scheduled = cherrypy.engine.publish('schedule_task', repo.delete, path)
-        assert scheduled
-        raise cherrypy.HTTPRedirect(form.redirect.data)
+        form.expected_confirm = path_obj.display_name
+        if form.is_submitted():
+            if form.validate():
+                cherrypy.engine.publish('schedule_task', repo.delete, path)
+                # Redirect to parent folder or to root if repo get deleted
+                if path_obj.isroot:
+                    raise cherrypy.HTTPRedirect(url_for('/'))
+                else:
+                    parent_path = repo.fstat(os.path.dirname(path_obj.path))
+                    raise cherrypy.HTTPRedirect(url_for('browse', repo, parent_path))
+            else:
+                raise cherrypy.HTTPError(400, form.error_message)
+        else:
+            raise cherrypy.HTTPError(405)

programming_language: Python
filename: pref_general.py
change_type: ModificationType.MODIFY
diff: @@ -54,15 +54,13 @@ class UserPasswordForm(CherryForm):
         _('Confirm new password'), validators=[InputRequired(_("Confirmation password is missing."))]
     )
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.new.validators += [
-            Length(
-                min=self.app.cfg.password_min_length,
-                max=self.app.cfg.password_max_length,
-                message=_('Password must have between %(min)d and %(max)d characters.'),
-            )
-        ]
+    def validate_new(self, field):
+        validator = Length(
+            min=self.app.cfg.password_min_length,
+            max=self.app.cfg.password_max_length,
+            message=_('Password must have between %(min)d and %(max)d characters.'),
+        )
+        validator(self, field)
 
     @property
     def app(self):

programming_language: Python
filename: test_page_admin.py
change_type: ModificationType.MODIFY
diff: @@ -267,6 +267,16 @@ class AdminUsersAsAdminTest(AbstractAdminTest):
         self.assertStatus(200)
         self.assertInBody("can&#39;t delete admin user")
 
+    def test_delete_user_method_get(self):
+        # Given a user
+        self.app.store.add_user('newuser')
+        # When trying to delete this user using method GET
+        self.getPage("/admin/users/?action=delete&username=newuser", method='GET')
+        # Then page return without error
+        self.assertStatus(200)
+        # Then user is not deleted
+        self.assertIsNotNone(self.app.store.get_user('newuser'))
+
     def test_change_password_with_too_short(self):
         self._edit_user(self.USERNAME, password='short')
         self.assertInBody("Password must have between 8 and 128 characters.")

programming_language: Python
filename: test_page_delete.py
change_type: ModificationType.MODIFY
diff: @@ -37,33 +37,59 @@ class DeleteRepoTest(rdiffweb.test.WebCase):
 
     login = True
 
-    def _delete(self, user, repo, confirm, redirect=None):
+    def _delete(self, user, repo, confirm):
         body = {}
         if confirm is not None:
             body.update({'confirm': confirm})
-        if redirect is not None:
-            body['redirect'] = redirect
         self.getPage("/delete/" + user + "/" + repo + "/", method="POST", body=body)
 
     @skipIf(RDIFF_BACKUP_VERSION < (2, 0, 1), "rdiff-backup-delete is available since 2.0.1")
     @parameterized.expand(
         [
-            ("with_dir", 'admin', '/testcases/Revisions', 'Revisions', 303, 404),
+            ("with_dir", 'admin', '/testcases/Revisions', 'Revisions', 303, 404, '/browse/admin/testcases'),
             ("with_dir_wrong_confirmation", 'admin', '/testcases/Revisions', 'invalid', 400, 200),
-            ("with_file", 'admin', '/testcases/Revisions/Data', 'Data', 303, 404),
+            ("with_file", 'admin', '/testcases/Revisions/Data', 'Data', 303, 404, '/browse/admin/testcases/Revisions'),
             ("with_file_wrong_confirmation", 'admin', '/testcases/Revisions/Data', 'invalid', 400, 200),
             ("with_invalid", 'admin', '/testcases/invalid', 'invalid', 404, 404),
-            ("with_broken_symlink", 'admin', '/testcases/BrokenSymlink', 'BrokenSymlink', 303, 404),
-            ("with_utf8", 'admin', '/testcases/R%C3%A9pertoire%20Existant', 'Répertoire Existant', 303, 404),
+            (
+                "with_broken_symlink",
+                'admin',
+                '/testcases/BrokenSymlink',
+                'BrokenSymlink',
+                303,
+                404,
+                '/browse/admin/testcases',
+            ),
+            (
+                "with_utf8",
+                'admin',
+                '/testcases/R%C3%A9pertoire%20Existant',
+                'Répertoire Existant',
+                303,
+                404,
+                '/browse/admin/testcases',
+            ),
             ("with_rdiff_backup_data", 'admin', '/testcases/rdiff-backup-data', 'rdiff-backup-data', 404, 404),
-            ("with_quoted_path", 'admin', '/testcases/Char%20%3B090%20to%20quote', 'Char Z to quote', 303, 404),
+            (
+                "with_quoted_path",
+                'admin',
+                '/testcases/Char%20%3B090%20to%20quote',
+                'Char Z to quote',
+                303,
+                404,
+                '/browse/admin/testcases',
+            ),
         ]
     )
-    def test_delete_path(self, unused, username, path, confirmation, expected_status, expected_history_status):
+    def test_delete_path(
+        self, unused, username, path, confirmation, expected_status, expected_history_status, expected_redirect=None
+    ):
         # When trying to delete a file or a folder with a confirmation
         self._delete(username, path, confirmation)
         # Then a status is returned
         self.assertStatus(expected_status)
+        if expected_redirect:
+            self.assertHeaderItemValue('Location', self.baseurl + expected_redirect)
         # Check filesystem
         sleep(1)
         self.getPage("/history/" + username + "/" + path)
@@ -78,6 +104,7 @@ class DeleteRepoTest(rdiffweb.test.WebCase):
         # Delete repo
         self._delete(self.USERNAME, self.REPO, 'testcases')
         self.assertStatus(303)
+        self.assertHeaderItemValue('Location', self.baseurl + '/')
         # Check filesystem
         sleep(1)
         self.assertEqual(['broker-repo'], [r.name for r in self.app.store.get_user('admin').repo_objs])
@@ -89,6 +116,7 @@ class DeleteRepoTest(rdiffweb.test.WebCase):
         # Then delete it.
         self._delete(self.USERNAME, self.REPO, 'testcases')
         self.assertStatus(303)
+        self.assertHeaderItemValue('Location', self.baseurl + '/')
         # Check filesystem
         sleep(1)
         self.assertEqual(['broker-repo'], [r.name for r in self.app.store.get_user('admin').repo_objs])
@@ -125,10 +153,9 @@ class DeleteRepoTest(rdiffweb.test.WebCase):
         user_obj.user_root = self.testcases
         self.assertEqual(['broker-repo', 'testcases'], [r.name for r in user_obj.repo_objs])
 
-        self._delete('anotheruser', 'testcases', 'testcases', redirect='/admin/repos/')
+        self._delete('anotheruser', 'testcases', 'testcases')
         self.assertStatus(303)
-        location = self.assertHeader('Location')
-        self.assertTrue(location.endswith('/admin/repos/'))
+        self.assertHeaderItemValue('Location', self.baseurl + '/')
 
         # Check filesystem
         sleep(1)
@@ -147,11 +174,10 @@ class DeleteRepoTest(rdiffweb.test.WebCase):
         # Login as maintainer
         self._login('maintainer', 'password')
 
-        # Try to delete own own repo
-        self._delete('maintainer', 'testcases', 'testcases', redirect='/admin/repos/')
+        # Try to delete your own repo
+        self._delete('maintainer', 'testcases', 'testcases')
         self.assertStatus(303)
-        location = self.assertHeader('Location')
-        self.assertTrue(location.endswith('/admin/repos/'))
+        self.assertHeaderItemValue('Location', self.baseurl + '/')
 
         # Check filesystem
         sleep(1)
@@ -169,7 +195,7 @@ class DeleteRepoTest(rdiffweb.test.WebCase):
         self._login('user', 'password')
 
         # Try to delete own own repo
-        self._delete('user', 'testcases', 'testcases', redirect='/admin/repos/')
+        self._delete('user', 'testcases', 'testcases')
         self.assertStatus(403)
 
         # Check database don't change
@@ -183,3 +209,13 @@ class DeleteRepoTest(rdiffweb.test.WebCase):
         self._delete(self.USERNAME, repo, repo)
         # Then a 404 is return to the user
         self.assertStatus(404)
+
+    def test_delete_method_get(self):
+        # Given a user with repo
+        self.assertEqual(['broker-repo', 'testcases'], [r.name for r in self.app.store.get_user('admin').repo_objs])
+        # When trying to deleted repo with GET method
+        self.getPage("/delete/" + self.USERNAME + "/" + self.REPO + "/?confirm=" + self.REPO, method="GET")
+        # Then An error is returned
+        self.assertStatus(405)
+        # Then repo still exists
+        self.assertEqual(['broker-repo', 'testcases'], [r.name for r in self.app.store.get_user('admin').repo_objs])

programming_language: Python