CVE-2020-5240 (Python, published 2020-03-13T22:15Z)
Description: value: 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 v3 base score: 8.5)
Weakness: CWE-863 \u2014 Incorrect Authorization
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.
Repository: https://github.com/labd/wagtail-2fa
Files changed:
src/wagtail_2fa/views.py
tests/test_views.py
Commit message: 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.


Vulnerable code:
from wagtail_2fa.views import DeviceListView


Fixed code:
from django.core.exceptions import PermissionDenied
    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

    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

import pytest
from django.core.exceptions import PermissionDenied
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.http.response import Http404
from wagtail_2fa.views import DeviceListView, DeviceDeleteView, DeviceUpdateView


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)