CVE-2024-35228
Description
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.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
wagtailPyPI | >= 6.0.0, < 6.0.5 | 6.0.5 |
wagtailPyPI | >= 6.1.0, < 6.1.2 | 6.1.2 |
Patches
1284f75a6f91fRestore permission check on settings EditView
3 files changed · +289 −33
wagtail/contrib/settings/tests/generic/test_admin.py+144 −16 modified@@ -77,11 +77,6 @@ def edit_url(self, setting): 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 @@ def test_file_upload_multipart(self): # 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 @@ def test_create_restricted_field_without_permission(self): 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 @@ def setUp(self): 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 @@ def test_for_request(self): ) 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):
wagtail/contrib/settings/tests/site_specific/test_admin.py+142 −17 modified@@ -73,11 +73,6 @@ def edit_url(self, setting, site_pk=1): 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 @@ def test_file_upload_multipart(self): # 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 @@ def test_create_restricted_field_without_permission(self): 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 @@ def setUp(self): 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 @@ def test_get_redirect_to_relevant_instance_invalid(self): 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(
wagtail/contrib/settings/views.py+3 −0 modified@@ -14,6 +14,7 @@ ) 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)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.