CVE-2020-5240 (published 2020-03-13T22:15Z)

In wagtail-2fa before 1.4.1, any user with access to the CMS can view and delete other users 2FA devices by going to the correct path. The user does not require special permissions in order to do so. By deleting the other users device they can disable the target users 2FA devices and potentially compromise the account if they figure out their password. The problem has been patched in version 1.4.1.

Severity: MEDIUM
CVSS v2 base score: 5.5 (AV:N/AC:L/Au:S/C:P/I:P/A:N)
CVSS v3 base score: 8.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:H/A:N)

Weakness information:
cwe_id: CWE-863
cwe_name: Incorrect Authorization
description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are incorrectly applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/labd/wagtail-2fa
commit_details: repo_url: https://github.com/labd/wagtail-2fa
author: Nicole Nisters
author_date: 2020-02-10 14:58:24+01:00
msg: Prevent unauthorized users managing others' device

This adds a permissions check on the device list and delete views. When
a user has the 'change_user' permission, they are allowed to view
and delete other users' 2FA devices. If they don't have the
permission, they are only allowed to manage their own devices.
repository: repo_url: https://github.com/labd/wagtail-2fa
repo_name: labd/wagtail-2fa
description: 2 Factor Authentication for Wagtail
repo_language: Python
owner: labd
file_changes: filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -1,3 +1,4 @@
+from django.core.exceptions import PermissionDenied
 import qrcode
 import qrcode.image.svg
 from django.conf import settings
@@ -75,6 +76,15 @@ class DeviceListView(OtpRequiredMixin, ListView):
         context['user_id'] = int(self.kwargs['user_id'])
         return context
 
+    def dispatch(self, request, *args, **kwargs):
+        if (int(self.kwargs["user_id"]) == request.user.pk or
+                request.user.has_perm("user.change_user")):
+            if not self.user_allowed(request.user):
+                return self.handle_no_permission(request)
+
+            return super(OtpRequiredMixin, self).dispatch(request, *args, **kwargs)
+        raise PermissionDenied
+
 
 class DeviceCreateView(OtpRequiredMixin, FormView):
     form_class = forms.DeviceForm
@@ -134,6 +144,17 @@ class DeviceDeleteView(OtpRequiredMixin, DeleteView):
     def get_success_url(self):
         return reverse('wagtail_2fa_device_list', kwargs={'user_id': self.request.POST.get('user_id')})
 
+    def dispatch(self, request, *args, **kwargs):
+        device = TOTPDevice.objects.get(**self.kwargs)
+
+        if device.user.pk == request.user.pk or request.user.has_perm("user.change_user"):
+            if not self.user_allowed(request.user):
+                return self.handle_no_permission(request)
+
+            return super(OtpRequiredMixin, self).dispatch(request, *args, **kwargs)
+
+        raise PermissionDenied
+
 
 class DeviceQRCodeView(OtpRequiredMixin, View):
     # require OTP if configured

programming_language: Python
filename: test_views.py
change_type: ModificationType.MODIFY
diff: @@ -1,12 +1,16 @@
+import pytest
+from django.core.exceptions import PermissionDenied
+from django.contrib.auth.models import Permission
+from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from unittest.mock import patch
+from django.http.response import Http404
 
 from django.contrib.auth import get_user_model
 from django.urls import reverse
 from django_otp import DEVICE_ID_SESSION_KEY
 from django_otp.plugins.otp_totp.models import TOTPDevice
-from wagtail_2fa.views import DeviceListView
-
+from wagtail_2fa.views import DeviceListView, DeviceDeleteView, DeviceUpdateView
 
 def test_device_list_view(admin_client, admin_user, django_assert_num_queries):
     with override_settings(WAGTAIL_2FA_REQUIRED=True):
@@ -129,3 +133,70 @@ def test_delete_user_device_unauthorized(client, user, monkeypatch):
         })
         assert response.status_code == 302
         assert TOTPDevice.objects.all().count() == 1
+
+
+class TestViewsChangeUserPermission:
+    """Test suite which ensures that:
+        - users without the change_user permission cannot manage other users' 2FA devices
+        - users can manage their own devices
+    """
+
+    def test_verified_user_has_no_change_user_perm(self, verified_user):
+        """Sanity check."""
+        assert not verified_user.has_perm("user.change_user")
+
+    def test_device_list_view_for_own_user_returns_200(self, verified_user, rf):
+        with override_settings(WAGTAIL_2FA_REQUIRED=True):
+            request = rf.get('foo')
+            request.user = verified_user
+
+            response = DeviceListView.as_view()(request, user_id=verified_user.id)
+            assert response.status_code == 200
+
+    def test_device_list_view_for_other_user_raises_error(self, user, verified_user, rf):
+        with override_settings(WAGTAIL_2FA_REQUIRED=True):
+            request = rf.get('foo')
+            request.user = verified_user
+
+            with pytest.raises(PermissionDenied):
+                response = DeviceListView.as_view()(request, user_id=user.id)
+
+    def test_device_delete_view_for_own_user_returns_200(self, verified_user, rf):
+        with override_settings(WAGTAIL_2FA_REQUIRED=True):
+            device = TOTPDevice.objects.devices_for_user(verified_user, confirmed=True).first()
+            request = rf.get('foo')
+            request.user = verified_user
+
+            response = DeviceDeleteView.as_view()(request, pk=device.id)
+            assert response.status_code == 200
+
+    def test_device_delete_view_for_other_user_raises_error(self, user, verified_user, rf):
+        with override_settings(WAGTAIL_2FA_REQUIRED=True):
+            other_device = TOTPDevice.objects.create(name='Initial', user=user, confirmed=True)
+
+            device = TOTPDevice.objects.devices_for_user(verified_user, confirmed=True).first()
+            request = rf.get('foo')
+            request.user = verified_user
+
+            with pytest.raises(PermissionDenied):
+                response = DeviceDeleteView.as_view()(request, pk=other_device.id)
+
+    def test_device_update_view_for_own_user_returns_200(self, verified_user, rf):
+        with override_settings(WAGTAIL_2FA_REQUIRED=True):
+            device = TOTPDevice.objects.devices_for_user(verified_user, confirmed=True).first()
+            request = rf.get('foo')
+            request.user = verified_user
+
+            response = DeviceUpdateView.as_view()(request, pk=device.id)
+            assert response.status_code == 200
+
+    def test_device_update_view_for_other_user_raises_error(self, user, verified_user, rf):
+        with override_settings(WAGTAIL_2FA_REQUIRED=True):
+            other_device = TOTPDevice.objects.create(name='Initial', user=user, confirmed=True)
+
+            device = TOTPDevice.objects.devices_for_user(verified_user, confirmed=True).first()
+            request = rf.get('foo')
+            request.user = verified_user
+
+            with pytest.raises(Http404):
+                response = DeviceUpdateView.as_view()(request, pk=other_device.id)

programming_language: Python