Skip to content

Commit 422791e

Browse files
committed
Mitigate CSRF on repository deletion and user deletion #214 #215
1 parent 28258e5 commit 422791e

File tree

6 files changed

+109
-52
lines changed

6 files changed

+109
-52
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ Professional support for Rdiffweb is available by contacting [IKUS Soft](https:/
107107

108108
# Changelog
109109

110+
## 2.4.5 (2002-09-16)
111+
112+
This releases include a security fix. If you are using an earlier version, you should upgrade to this release immediately.
113+
114+
* Mitigate CSRF on repository deletion and user deletion [CVE-2022-3232](https://nvd.nist.gov/vuln/detail/CVE-2022-3232) #214 #215
115+
110116
## 2.4.4 (2002-09-15)
111117

112118
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
117123

118124
This releases include a security fix. If you are using an earlier version, you should upgrade to this release immediately.
119125

120-
* Mitigate CSRF in profile's SSH Keys #212
126+
* Mitigate CSRF in profile's SSH Keys [CVE-2022-3221](https://nvd.nist.gov/vuln/detail/CVE-2022-3221) #212
121127

122128
## 2.4.2 (2022-09-12)
123129

rdiffweb/controller/page_admin.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -190,15 +190,13 @@ class UserForm(CherryForm):
190190
_('Quota Used'), validators=[validators.optional()], description=_("Disk spaces (in bytes) used by this user.")
191191
)
192192

193-
def __init__(self, *args, **kwargs):
194-
super().__init__(*args, **kwargs)
195-
self.password.validators += [
196-
validators.length(
197-
min=self.app.cfg.password_min_length,
198-
max=self.app.cfg.password_max_length,
199-
message=_('Password must have between %(min)d and %(max)d characters.'),
200-
)
201-
]
193+
def validate_password(self, field):
194+
validator = validators.length(
195+
min=self.app.cfg.password_min_length,
196+
max=self.app.cfg.password_max_length,
197+
message=_('Password must have between %(min)d and %(max)d characters.'),
198+
)
199+
validator(self, field)
202200

203201
@property
204202
def app(self):
@@ -340,7 +338,9 @@ def users(self, username=None, criteria=u"", search=u"", action=u"", **kwargs):
340338
else:
341339
flash(_("Cannot edit user `%s`: user doesn't exists") % username, level='error')
342340
elif action == 'delete':
343-
self._delete_user(action, DeleteUserForm())
341+
form = DeleteUserForm()
342+
if form.validate_on_submit():
343+
self._delete_user(action, form)
344344

345345
params = {
346346
"add_form": UserForm(formdata=None),

rdiffweb/controller/page_delete.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,29 @@
2222
# Define the logger
2323

2424
import logging
25+
import os
2526

2627
import cherrypy
27-
from wtforms import validators
2828
from wtforms.fields.core import StringField
29+
from wtforms.validators import DataRequired, ValidationError
2930

3031
from rdiffweb.controller import Controller
3132
from rdiffweb.controller.cherrypy_wtf import CherryForm
3233
from rdiffweb.controller.dispatch import poppath
3334
from rdiffweb.controller.filter_authorization import is_maintainer
3435
from rdiffweb.core.librdiff import AccessDeniedError, DoesNotExistError
36+
from rdiffweb.core.rdw_templating import url_for
3537
from rdiffweb.tools.i18n import ugettext as _
3638

3739
_logger = logging.getLogger(__name__)
3840

3941

4042
class DeleteRepoForm(CherryForm):
41-
confirm = StringField(_('Confirmation'), validators=[validators.data_required()])
42-
redirect = StringField(default='/')
43+
confirm = StringField(_('Confirmation'), validators=[DataRequired()])
44+
45+
def validate_confirm(self, field):
46+
if self.confirm.data != self.expected_confirm:
47+
raise ValidationError(_('Invalid value, must be: %s') % self.expected_confirm)
4348

4449

4550
@poppath()
@@ -61,15 +66,17 @@ def default(self, path=b"", **kwargs):
6166

6267
# validate form
6368
form = DeleteRepoForm()
64-
if not form.validate():
65-
raise cherrypy.HTTPError(400, form.error_message)
66-
67-
# Validate the name
68-
if form.confirm.data != path_obj.display_name:
69-
_logger.info("do not delete repo, bad confirmation %r != %r", form.confirm.data, path_obj.display_name)
70-
raise cherrypy.HTTPError(400, 'bad confirmation')
71-
72-
# Delete repository in background using a schedule task.
73-
scheduled = cherrypy.engine.publish('schedule_task', repo.delete, path)
74-
assert scheduled
75-
raise cherrypy.HTTPRedirect(form.redirect.data)
69+
form.expected_confirm = path_obj.display_name
70+
if form.is_submitted():
71+
if form.validate():
72+
cherrypy.engine.publish('schedule_task', repo.delete, path)
73+
# Redirect to parent folder or to root if repo get deleted
74+
if path_obj.isroot:
75+
raise cherrypy.HTTPRedirect(url_for('/'))
76+
else:
77+
parent_path = repo.fstat(os.path.dirname(path_obj.path))
78+
raise cherrypy.HTTPRedirect(url_for('browse', repo, parent_path))
79+
else:
80+
raise cherrypy.HTTPError(400, form.error_message)
81+
else:
82+
raise cherrypy.HTTPError(405)

rdiffweb/controller/pref_general.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,13 @@ class UserPasswordForm(CherryForm):
5454
_('Confirm new password'), validators=[InputRequired(_("Confirmation password is missing."))]
5555
)
5656

57-
def __init__(self, *args, **kwargs):
58-
super().__init__(*args, **kwargs)
59-
self.new.validators += [
60-
Length(
61-
min=self.app.cfg.password_min_length,
62-
max=self.app.cfg.password_max_length,
63-
message=_('Password must have between %(min)d and %(max)d characters.'),
64-
)
65-
]
57+
def validate_new(self, field):
58+
validator = Length(
59+
min=self.app.cfg.password_min_length,
60+
max=self.app.cfg.password_max_length,
61+
message=_('Password must have between %(min)d and %(max)d characters.'),
62+
)
63+
validator(self, field)
6664

6765
@property
6866
def app(self):

rdiffweb/controller/tests/test_page_admin.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,16 @@ def test_delete_user_admin(self):
267267
self.assertStatus(200)
268268
self.assertInBody("can't delete admin user")
269269

270+
def test_delete_user_method_get(self):
271+
# Given a user
272+
self.app.store.add_user('newuser')
273+
# When trying to delete this user using method GET
274+
self.getPage("/admin/users/?action=delete&username=newuser", method='GET')
275+
# Then page return without error
276+
self.assertStatus(200)
277+
# Then user is not deleted
278+
self.assertIsNotNone(self.app.store.get_user('newuser'))
279+
270280
def test_change_password_with_too_short(self):
271281
self._edit_user(self.USERNAME, password='short')
272282
self.assertInBody("Password must have between 8 and 128 characters.")

rdiffweb/controller/tests/test_page_delete.py

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,33 +37,59 @@ class DeleteRepoTest(rdiffweb.test.WebCase):
3737

3838
login = True
3939

40-
def _delete(self, user, repo, confirm, redirect=None):
40+
def _delete(self, user, repo, confirm):
4141
body = {}
4242
if confirm is not None:
4343
body.update({'confirm': confirm})
44-
if redirect is not None:
45-
body['redirect'] = redirect
4644
self.getPage("/delete/" + user + "/" + repo + "/", method="POST", body=body)
4745

4846
@skipIf(RDIFF_BACKUP_VERSION < (2, 0, 1), "rdiff-backup-delete is available since 2.0.1")
4947
@parameterized.expand(
5048
[
51-
("with_dir", 'admin', '/testcases/Revisions', 'Revisions', 303, 404),
49+
("with_dir", 'admin', '/testcases/Revisions', 'Revisions', 303, 404, '/browse/admin/testcases'),
5250
("with_dir_wrong_confirmation", 'admin', '/testcases/Revisions', 'invalid', 400, 200),
53-
("with_file", 'admin', '/testcases/Revisions/Data', 'Data', 303, 404),
51+
("with_file", 'admin', '/testcases/Revisions/Data', 'Data', 303, 404, '/browse/admin/testcases/Revisions'),
5452
("with_file_wrong_confirmation", 'admin', '/testcases/Revisions/Data', 'invalid', 400, 200),
5553
("with_invalid", 'admin', '/testcases/invalid', 'invalid', 404, 404),
56-
("with_broken_symlink", 'admin', '/testcases/BrokenSymlink', 'BrokenSymlink', 303, 404),
57-
("with_utf8", 'admin', '/testcases/R%C3%A9pertoire%20Existant', 'Répertoire Existant', 303, 404),
54+
(
55+
"with_broken_symlink",
56+
'admin',
57+
'/testcases/BrokenSymlink',
58+
'BrokenSymlink',
59+
303,
60+
404,
61+
'/browse/admin/testcases',
62+
),
63+
(
64+
"with_utf8",
65+
'admin',
66+
'/testcases/R%C3%A9pertoire%20Existant',
67+
'Répertoire Existant',
68+
303,
69+
404,
70+
'/browse/admin/testcases',
71+
),
5872
("with_rdiff_backup_data", 'admin', '/testcases/rdiff-backup-data', 'rdiff-backup-data', 404, 404),
59-
("with_quoted_path", 'admin', '/testcases/Char%20%3B090%20to%20quote', 'Char Z to quote', 303, 404),
73+
(
74+
"with_quoted_path",
75+
'admin',
76+
'/testcases/Char%20%3B090%20to%20quote',
77+
'Char Z to quote',
78+
303,
79+
404,
80+
'/browse/admin/testcases',
81+
),
6082
]
6183
)
62-
def test_delete_path(self, unused, username, path, confirmation, expected_status, expected_history_status):
84+
def test_delete_path(
85+
self, unused, username, path, confirmation, expected_status, expected_history_status, expected_redirect=None
86+
):
6387
# When trying to delete a file or a folder with a confirmation
6488
self._delete(username, path, confirmation)
6589
# Then a status is returned
6690
self.assertStatus(expected_status)
91+
if expected_redirect:
92+
self.assertHeaderItemValue('Location', self.baseurl + expected_redirect)
6793
# Check filesystem
6894
sleep(1)
6995
self.getPage("/history/" + username + "/" + path)
@@ -78,6 +104,7 @@ def test_delete_repo(self):
78104
# Delete repo
79105
self._delete(self.USERNAME, self.REPO, 'testcases')
80106
self.assertStatus(303)
107+
self.assertHeaderItemValue('Location', self.baseurl + '/')
81108
# Check filesystem
82109
sleep(1)
83110
self.assertEqual(['broker-repo'], [r.name for r in self.app.store.get_user('admin').repo_objs])
@@ -89,6 +116,7 @@ def test_delete_repo_with_slash(self):
89116
# Then delete it.
90117
self._delete(self.USERNAME, self.REPO, 'testcases')
91118
self.assertStatus(303)
119+
self.assertHeaderItemValue('Location', self.baseurl + '/')
92120
# Check filesystem
93121
sleep(1)
94122
self.assertEqual(['broker-repo'], [r.name for r in self.app.store.get_user('admin').repo_objs])
@@ -125,10 +153,9 @@ def test_delete_repo_as_admin(self):
125153
user_obj.user_root = self.testcases
126154
self.assertEqual(['broker-repo', 'testcases'], [r.name for r in user_obj.repo_objs])
127155

128-
self._delete('anotheruser', 'testcases', 'testcases', redirect='/admin/repos/')
156+
self._delete('anotheruser', 'testcases', 'testcases')
129157
self.assertStatus(303)
130-
location = self.assertHeader('Location')
131-
self.assertTrue(location.endswith('/admin/repos/'))
158+
self.assertHeaderItemValue('Location', self.baseurl + '/')
132159

133160
# Check filesystem
134161
sleep(1)
@@ -147,11 +174,10 @@ def test_delete_repo_as_maintainer(self):
147174
# Login as maintainer
148175
self._login('maintainer', 'password')
149176

150-
# Try to delete own own repo
151-
self._delete('maintainer', 'testcases', 'testcases', redirect='/admin/repos/')
177+
# Try to delete your own repo
178+
self._delete('maintainer', 'testcases', 'testcases')
152179
self.assertStatus(303)
153-
location = self.assertHeader('Location')
154-
self.assertTrue(location.endswith('/admin/repos/'))
180+
self.assertHeaderItemValue('Location', self.baseurl + '/')
155181

156182
# Check filesystem
157183
sleep(1)
@@ -169,7 +195,7 @@ def test_delete_repo_as_user(self):
169195
self._login('user', 'password')
170196

171197
# Try to delete own own repo
172-
self._delete('user', 'testcases', 'testcases', redirect='/admin/repos/')
198+
self._delete('user', 'testcases', 'testcases')
173199
self.assertStatus(403)
174200

175201
# Check database don't change
@@ -183,3 +209,13 @@ def test_delete_repo_does_not_exists(self):
183209
self._delete(self.USERNAME, repo, repo)
184210
# Then a 404 is return to the user
185211
self.assertStatus(404)
212+
213+
def test_delete_method_get(self):
214+
# Given a user with repo
215+
self.assertEqual(['broker-repo', 'testcases'], [r.name for r in self.app.store.get_user('admin').repo_objs])
216+
# When trying to deleted repo with GET method
217+
self.getPage("/delete/" + self.USERNAME + "/" + self.REPO + "/?confirm=" + self.REPO, method="GET")
218+
# Then An error is returned
219+
self.assertStatus(405)
220+
# Then repo still exists
221+
self.assertEqual(['broker-repo', 'testcases'], [r.name for r in self.app.store.get_user('admin').repo_objs])

0 commit comments

Comments
 (0)