CVE-2024-35228 (published 2024-05-30T19:15Z)

Wagtail is an open source content management system built on Django. Due to an improperly applied permission check in the `wagtail.contrib.settings` module, a user with access to the Wagtail admin and knowledge of the URL of the edit view for a settings model can access and update that setting, even when they have not been granted permission over the model. The vulnerability is not exploitable by an ordinary site visitor without access to the Wagtail admin. Patched versions have been released as Wagtail 6.0.5 and 6.1.2. Wagtail releases prior to 6.0 are unaffected. Users are advised to upgrade. Site owners who are unable to upgrade to a patched version can avoid the vulnerability in `ModelViewSet` by registering the model as a snippet instead. No workaround is available for `wagtail.contrib.settings`.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/wagtail/wagtail
commit_details: repo_url: https://github.com/wagtail/wagtail
author: Matt Westcott
author_date: 2024-05-22 20:34:30+01:00
msg: Restore permission check on settings EditView
repository: repo_url: https://github.com/wagtail/wagtail
repo_name: wagtail/wagtail
description: A Django content management system focused on flexibility and user experience
repo_language: Python
owner: wagtail
file_changes: filename: test_admin.py
change_type: ModificationType.MODIFY
diff: @@ -77,11 +77,6 @@ class BaseTestGenericSettingView(WagtailTestUtils, TestCase):
 class TestGenericSettingCreateView(BaseTestGenericSettingView):
     def setUp(self):
         self.user = self.login()
-        self.user.user_permissions.add(
-            Permission.objects.get(
-                content_type__app_label="wagtailadmin", codename="access_admin"
-            )
-        )
 
     def test_get_edit(self):
         response = self.get()
@@ -113,11 +108,62 @@ class TestGenericSettingCreateView(BaseTestGenericSettingView):
         # Ensure the form supports file uploads
         self.assertContains(response, 'enctype="multipart/form-data"')
 
-    def test_create_restricted_field_without_permission(self):
+    def test_create_restricted_field_without_any_permission(self):
+        # User has no permissions over the setting model, only access to the admin
         self.user.is_superuser = False
         self.user.save()
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+        )
 
         self.assertFalse(TestPermissionedGenericSetting.objects.exists())
+        # GET should redirect away with permission denied
+        response = self.get(setting=TestPermissionedGenericSetting)
+        self.assertRedirects(response, status_code=302, expected_url="/admin/")
+
+        # the GET might create a setting object, depending on when the permission check is done,
+        # so remove any created objects prior to testing the POST
+        TestPermissionedGenericSetting.objects.all().delete()
+
+        # POST should redirect away with permission denied
+        response = self.post(
+            post_data={"sensitive_email": "test@example.com", "title": "test"},
+            setting=TestPermissionedGenericSetting,
+        )
+        self.assertRedirects(response, status_code=302, expected_url="/admin/")
+
+        # The retrieved setting should contain none of the submitted data
+        setting = TestPermissionedGenericSetting.load()
+        self.assertEqual(setting.title, "")
+        self.assertEqual(setting.sensitive_email, "")
+
+    def test_create_restricted_field_without_field_permission(self):
+        # User has edit permission over the setting model, but not the sensitive_email field
+        self.user.is_superuser = False
+        self.user.save()
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+            Permission.objects.get(
+                content_type__app_label="tests",
+                codename="change_testpermissionedgenericsetting",
+            ),
+        )
+
+        self.assertFalse(TestPermissionedGenericSetting.objects.exists())
+        # GET should provide a form with title but not sensitive_email
+        response = self.get(setting=TestPermissionedGenericSetting)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("title", list(response.context["form"].fields))
+        self.assertNotIn("sensitive_email", list(response.context["form"].fields))
+
+        # the GET creates a setting object, so remove any created objects prior to testing the POST
+        TestPermissionedGenericSetting.objects.all().delete()
+
+        # POST should allow the title to be set, but not the sensitive_email
         response = self.post(
             post_data={"sensitive_email": "test@example.com", "title": "test"},
             setting=TestPermissionedGenericSetting,
@@ -129,11 +175,31 @@ class TestGenericSettingCreateView(BaseTestGenericSettingView):
         self.assertEqual(settings.sensitive_email, "")
 
     def test_create_restricted_field(self):
+        # User has edit permission over the setting model, including the sensitive_email field
         self.user.is_superuser = False
         self.user.save()
         self.user.user_permissions.add(
-            Permission.objects.get(codename="can_edit_sensitive_email_generic_setting")
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+            Permission.objects.get(
+                content_type__app_label="tests",
+                codename="change_testpermissionedgenericsetting",
+            ),
+            Permission.objects.get(codename="can_edit_sensitive_email_generic_setting"),
         )
+
+        self.assertFalse(TestPermissionedGenericSetting.objects.exists())
+        # GET should provide a form with title and sensitive_email
+        response = self.get(setting=TestPermissionedGenericSetting)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("title", list(response.context["form"].fields))
+        self.assertIn("sensitive_email", list(response.context["form"].fields))
+
+        # the GET creates a setting object, so remove any created objects prior to testing the POST
+        TestPermissionedGenericSetting.objects.all().delete()
+
+        # POST should allow both title and sensitive_email to be set
         self.assertFalse(TestPermissionedGenericSetting.objects.exists())
         response = self.post(
             post_data={"sensitive_email": "test@example.com", "title": "test"},
@@ -153,11 +219,6 @@ class TestGenericSettingEditView(BaseTestGenericSettingView):
         self.test_setting.save()
 
         self.user = self.login()
-        self.user.user_permissions.add(
-            Permission.objects.get(
-                content_type__app_label="wagtailadmin", codename="access_admin"
-            )
-        )
 
     def test_get_edit(self):
         response = self.get()
@@ -206,48 +267,115 @@ class TestGenericSettingEditView(BaseTestGenericSettingView):
         )
 
     def test_edit_restricted_field(self):
+        # User has edit permission over the setting model, including the sensitive_email field
         test_setting = TestPermissionedGenericSetting()
         test_setting.sensitive_email = "test@example.com"
+        test_setting.title = "Old title"
         test_setting.save()
         self.user.is_superuser = False
         self.user.save()
 
         self.user.user_permissions.add(
-            Permission.objects.get(codename="can_edit_sensitive_email_generic_setting")
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+            Permission.objects.get(
+                content_type__app_label="tests",
+                codename="change_testpermissionedgenericsetting",
+            ),
+            Permission.objects.get(codename="can_edit_sensitive_email_generic_setting"),
         )
 
+        # GET should provide a form with title and sensitive_email
         response = self.get(setting=TestPermissionedGenericSetting)
         self.assertEqual(response.status_code, 200)
+        self.assertIn("title", list(response.context["form"].fields))
         self.assertIn("sensitive_email", list(response.context["form"].fields))
 
+        # POST should allow both title and sensitive_email to be set
         response = self.post(
             setting=TestPermissionedGenericSetting,
-            post_data={"sensitive_email": "test-updated@example.com", "title": "title"},
+            post_data={
+                "sensitive_email": "test-updated@example.com",
+                "title": "New title",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         test_setting.refresh_from_db()
         self.assertEqual(test_setting.sensitive_email, "test-updated@example.com")
+        self.assertEqual(test_setting.title, "New title")
 
-    def test_edit_restricted_field_without_permission(self):
+    def test_edit_restricted_field_without_field_permission(self):
+        # User has edit permission over the setting model, but not the sensitive_email field
         test_setting = TestPermissionedGenericSetting()
         test_setting.sensitive_email = "test@example.com"
+        test_setting.title = "Old title"
         test_setting.save()
         self.user.is_superuser = False
         self.user.save()
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+            Permission.objects.get(
+                content_type__app_label="tests",
+                codename="change_testpermissionedgenericsetting",
+            ),
+        )
 
+        # GET should provide a form with title but not sensitive_email
         response = self.get(setting=TestPermissionedGenericSetting)
         self.assertEqual(response.status_code, 200)
+        self.assertIn("title", list(response.context["form"].fields))
         self.assertNotIn("sensitive_email", list(response.context["form"].fields))
 
+        # POST should allow the title to be set, but not the sensitive_email
         response = self.post(
             setting=TestPermissionedGenericSetting,
-            post_data={"sensitive_email": "test-updated@example.com", "title": "title"},
+            post_data={
+                "sensitive_email": "test-updated@example.com",
+                "title": "New title",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         test_setting.refresh_from_db()
         self.assertEqual(test_setting.sensitive_email, "test@example.com")
+        self.assertEqual(test_setting.title, "New title")
+
+    def test_edit_restricted_field_without_any_permission(self):
+        # User has no permissions over the setting model, only access to the admin
+        test_setting = TestPermissionedGenericSetting()
+        test_setting.sensitive_email = "test@example.com"
+        test_setting.title = "Old title"
+        test_setting.save()
+        self.user.is_superuser = False
+        self.user.save()
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+        )
+
+        # GET should redirect away with permission denied
+        response = self.get(setting=TestPermissionedGenericSetting)
+        self.assertRedirects(response, status_code=302, expected_url="/admin/")
+
+        # POST should redirect away with permission denied
+        response = self.post(
+            setting=TestPermissionedGenericSetting,
+            post_data={
+                "sensitive_email": "test-updated@example.com",
+                "title": "new title",
+            },
+        )
+        self.assertRedirects(response, status_code=302, expected_url="/admin/")
+
+        # The retrieved setting should be unchanged
+        test_setting.refresh_from_db()
+        self.assertEqual(test_setting.sensitive_email, "test@example.com")
+        self.assertEqual(test_setting.title, "Old title")
 
 
 class TestAdminPermission(WagtailTestUtils, TestCase):

programming_language: Python
filename: test_admin.py
change_type: ModificationType.MODIFY
diff: @@ -73,11 +73,6 @@ class BaseTestSiteSettingView(WagtailTestUtils, TestCase):
 class TestSiteSettingCreateView(BaseTestSiteSettingView):
     def setUp(self):
         self.user = self.login()
-        self.user.user_permissions.add(
-            Permission.objects.get(
-                content_type__app_label="wagtailadmin", codename="access_admin"
-            )
-        )
 
     def test_get_edit(self):
         response = self.get()
@@ -109,11 +104,61 @@ class TestSiteSettingCreateView(BaseTestSiteSettingView):
         # Ensure the form supports file uploads
         self.assertContains(response, 'enctype="multipart/form-data"')
 
-    def test_create_restricted_field_without_permission(self):
+    def test_create_restricted_field_without_any_permission(self):
+        # User has no permissions over the setting model, only access to the admin
+        self.user.is_superuser = False
+        self.user.save()
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+        )
+
+        self.assertFalse(TestPermissionedSiteSetting.objects.exists())
+        # GET should redirect away with permission denied
+        response = self.get(setting=TestPermissionedSiteSetting)
+        self.assertRedirects(response, status_code=302, expected_url="/admin/")
+
+        # the GET might create a setting object, depending on when the permission check is done,
+        # so remove any created objects prior to testing the POST
+
+        # POST should redirect away with permission denied
+        response = self.post(
+            post_data={"sensitive_email": "test@example.com", "title": "test"},
+            setting=TestPermissionedSiteSetting,
+        )
+        self.assertRedirects(response, status_code=302, expected_url="/admin/")
+
+        # The retrieved setting should contain none of the submitted data
+        settings = TestPermissionedSiteSetting.for_site(Site.objects.get(pk=1))
+        self.assertEqual(settings.title, "")
+        self.assertEqual(settings.sensitive_email, "")
+
+    def test_create_restricted_field_without_field_permission(self):
+        # User has edit permission over the setting model, but not the sensitive_email field
         self.user.is_superuser = False
         self.user.save()
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+            Permission.objects.get(
+                content_type__app_label="tests",
+                codename="change_testpermissionedsitesetting",
+            ),
+        )
 
         self.assertFalse(TestPermissionedSiteSetting.objects.exists())
+        # GET should provide a form with title but not sensitive_email
+        response = self.get(setting=TestPermissionedSiteSetting)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("title", list(response.context["form"].fields))
+        self.assertNotIn("sensitive_email", list(response.context["form"].fields))
+
+        # the GET creates a setting object, so remove any created objects prior to testing the POST
+        TestPermissionedSiteSetting.objects.all().delete()
+
+        # POST should allow the title to be set, but not the sensitive_email
         response = self.post(
             post_data={"sensitive_email": "test@example.com", "title": "test"},
             setting=TestPermissionedSiteSetting,
@@ -125,12 +170,30 @@ class TestSiteSettingCreateView(BaseTestSiteSettingView):
         self.assertEqual(settings.sensitive_email, "")
 
     def test_create_restricted_field(self):
+        # User has edit permission over the setting model, including the sensitive_email field
         self.user.is_superuser = False
         self.user.save()
         self.user.user_permissions.add(
-            Permission.objects.get(codename="can_edit_sensitive_email_site_setting")
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+            Permission.objects.get(
+                content_type__app_label="tests",
+                codename="change_testpermissionedsitesetting",
+            ),
+            Permission.objects.get(codename="can_edit_sensitive_email_site_setting"),
         )
         self.assertFalse(TestPermissionedSiteSetting.objects.exists())
+        # GET should provide a form with title and sensitive_email
+        response = self.get(setting=TestPermissionedSiteSetting)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("title", list(response.context["form"].fields))
+        self.assertIn("sensitive_email", list(response.context["form"].fields))
+
+        # the GET creates a setting object, so remove any created objects prior to testing the POST
+        TestPermissionedSiteSetting.objects.all().delete()
+
+        # POST should allow both title and sensitive_email to be set
         response = self.post(
             post_data={"sensitive_email": "test@example.com", "title": "test"},
             setting=TestPermissionedSiteSetting,
@@ -153,11 +216,6 @@ class TestSiteSettingEditView(BaseTestSiteSettingView):
         self.test_setting.save()
 
         self.user = self.login()
-        self.user.user_permissions.add(
-            Permission.objects.get(
-                content_type__app_label="wagtailadmin", codename="access_admin"
-            )
-        )
 
     def test_get_edit(self):
         response = self.get()
@@ -211,50 +269,117 @@ class TestSiteSettingEditView(BaseTestSiteSettingView):
         self.assertRedirects(response, status_code=302, expected_url="/admin/")
 
     def test_edit_restricted_field(self):
+        # User has edit permission over the setting model, including the sensitive_email field
         test_setting = TestPermissionedSiteSetting()
+        test_setting.title = "Old title"
         test_setting.sensitive_email = "test@example.com"
         test_setting.site = self.default_site
         test_setting.save()
         self.user.is_superuser = False
         self.user.save()
-
         self.user.user_permissions.add(
-            Permission.objects.get(codename="can_edit_sensitive_email_site_setting")
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+            Permission.objects.get(
+                content_type__app_label="tests",
+                codename="change_testpermissionedsitesetting",
+            ),
+            Permission.objects.get(codename="can_edit_sensitive_email_site_setting"),
         )
 
+        # GET should provide a form with title and sensitive_email
         response = self.get(setting=TestPermissionedSiteSetting)
         self.assertEqual(response.status_code, 200)
+        self.assertIn("title", list(response.context["form"].fields))
         self.assertIn("sensitive_email", list(response.context["form"].fields))
 
+        # POST should allow both title and sensitive_email to be set
         response = self.post(
             setting=TestPermissionedSiteSetting,
-            post_data={"sensitive_email": "test-updated@example.com", "title": "title"},
+            post_data={
+                "sensitive_email": "test-updated@example.com",
+                "title": "New title",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         test_setting.refresh_from_db()
         self.assertEqual(test_setting.sensitive_email, "test-updated@example.com")
+        self.assertEqual(test_setting.title, "New title")
 
-    def test_edit_restricted_field_without_permission(self):
+    def test_edit_restricted_field_without_field_permission(self):
+        # User has edit permission over the setting model, but not the sensitive_email field
         test_setting = TestPermissionedSiteSetting()
+        test_setting.title = "Old title"
         test_setting.sensitive_email = "test@example.com"
         test_setting.site = self.default_site
         test_setting.save()
         self.user.is_superuser = False
         self.user.save()
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+            Permission.objects.get(
+                content_type__app_label="tests",
+                codename="change_testpermissionedsitesetting",
+            ),
+        )
 
+        # GET should provide a form with title but not sensitive_email
         response = self.get(setting=TestPermissionedSiteSetting)
         self.assertEqual(response.status_code, 200)
+        self.assertIn("title", list(response.context["form"].fields))
         self.assertNotIn("sensitive_email", list(response.context["form"].fields))
 
+        # POST should allow the title to be set, but not the sensitive_email
         response = self.post(
             setting=TestPermissionedSiteSetting,
-            post_data={"sensitive_email": "test-updated@example.com", "title": "title"},
+            post_data={
+                "sensitive_email": "test-updated@example.com",
+                "title": "New title",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         test_setting.refresh_from_db()
         self.assertEqual(test_setting.sensitive_email, "test@example.com")
+        self.assertEqual(test_setting.title, "New title")
+
+    def test_edit_restricted_field_without_any_permission(self):
+        # User has no permissions over the setting model, only access to the admin
+        test_setting = TestPermissionedSiteSetting()
+        test_setting.title = "Old title"
+        test_setting.sensitive_email = "test@example.com"
+        test_setting.site = self.default_site
+        test_setting.save()
+        self.user.is_superuser = False
+        self.user.save()
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            ),
+        )
+
+        # GET should redirect away with permission denied
+        response = self.get(setting=TestPermissionedSiteSetting)
+        self.assertRedirects(response, status_code=302, expected_url="/admin/")
+
+        # POST should redirect away with permission denied
+        response = self.post(
+            setting=TestPermissionedSiteSetting,
+            post_data={
+                "sensitive_email": "test-updated@example.com",
+                "title": "New title",
+            },
+        )
+        self.assertRedirects(response, status_code=302, expected_url="/admin/")
+
+        # The retrieved setting should be unchanged
+        test_setting.refresh_from_db()
+        self.assertEqual(test_setting.sensitive_email, "test@example.com")
+        self.assertEqual(test_setting.title, "Old title")
 
 
 @override_settings(

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -14,6 +14,7 @@ from wagtail.admin.panels import (
 )
 from wagtail.admin.views import generic
 from wagtail.models import Site
+from wagtail.permission_policies import ModelPermissionPolicy
 
 from .forms import SiteSwitchForm
 from .models import BaseGenericSetting, BaseSiteSetting
@@ -81,11 +82,13 @@ def redirect_to_relevant_instance(request, app_name, model_name):
 class EditView(generic.EditView):
     template_name = "wagtailsettings/edit.html"
     error_message = gettext_lazy("The setting could not be saved due to errors.")
+    permission_required = "change"
 
     def setup(self, request, app_name, model_name, *args, **kwargs):
         self.app_name = app_name
         self.model_name = model_name
         self.model = get_model_from_url_params(app_name, model_name)
+        self.permission_policy = ModelPermissionPolicy(self.model)
         self.pk = kwargs.get(self.pk_url_kwarg)
         super().setup(request, app_name, model_name, *args, **kwargs)
 

programming_language: Python