From 7c125f6a6660a70a0568448b2e40db273a456479 Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Mon, 18 May 2026 18:55:29 +0200 Subject: [PATCH 1/2] Fixed #37105 -- Disallowed admin change form actions on different objects. Bug in f30acb184f75fd9260cfd6ddc48a3bbbd49f9c1d. Refs #12090. --- django/contrib/admin/options.py | 3 +++ .../admin/templates/admin/change_form.html | 2 +- tests/admin_views/test_actions.py | 24 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index e5a856e1327c..c35bf87e32b7 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -2105,6 +2105,9 @@ def _changeform_view(self, request, object_id, form_url, extra_context): and "_continue" not in request.POST and "_addanother" not in request.POST ): + selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME) + if len(selected) != 1 or selected[0] != str(obj.pk): + raise BadRequest queryset = self.model._default_manager.get_queryset() if response := self.response_action( request, queryset, action_location=ActionLocation.CHANGE_FORM diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html index f7dac55e0d9f..988e92e163b8 100644 --- a/django/contrib/admin/templates/admin/change_form.html +++ b/django/contrib/admin/templates/admin/change_form.html @@ -43,7 +43,7 @@
{% if action_form and not is_popup %} {% change_form_admin_actions %} - + {% endif %} {% if is_popup %}{% endif %} {% if to_field %}{% endif %} diff --git a/tests/admin_views/test_actions.py b/tests/admin_views/test_actions.py index 32bd1266dcb1..b723f76914ff 100644 --- a/tests/admin_views/test_actions.py +++ b/tests/admin_views/test_actions.py @@ -673,6 +673,30 @@ def test_run_action_permissions(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"OK") + def test_action_changeform_cannot_target_different_objects(self): + changeform_url = reverse( + "admin:admin_views_externalsubscriber_change", args=[self.s1.pk] + ) + external_subscriber = ExternalSubscriber.objects.create( + name="Jane Austen", email="jane@example.org" + ) + for invalid_checkbox_value in [ + [external_subscriber.pk], + [self.s1.pk, external_subscriber.pk], + [], + ]: + with self.subTest(invalid_checkbox_value=invalid_checkbox_value): + response = self.client.post( + changeform_url, + { + "CHANGE_FORM-action": "external_mail", + ACTION_CHECKBOX_NAME: invalid_checkbox_value, + "index": 0, + }, + ) + self.assertEqual(len(mail.outbox), 0) + self.assertEqual(response.status_code, 400) + def test_select_across_ignored(self): ExternalSubscriber.objects.create(name="Bob Bobson", email="bob@example.org") change_url = reverse( From 8fd29079ed1253f0cd88ccf330de30271a5d15e4 Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Wed, 20 May 2026 12:47:02 +0200 Subject: [PATCH 2/2] Refs #12090 -- Aligned admin change form and list minimum permission to run action. --- django/contrib/admin/options.py | 12 +++++------- tests/admin_views/test_actions.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index c35bf87e32b7..1772638748c3 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -2060,13 +2060,8 @@ def _changeform_view(self, request, object_id, form_url, extra_context): else: obj = self.get_object(request, unquote(object_id), to_field) - - if request.method == "POST": - if not self.has_change_permission(request, obj): - raise PermissionDenied - else: - if not self.has_view_or_change_permission(request, obj): - raise PermissionDenied + if not self.has_view_or_change_permission(request, obj): + raise PermissionDenied if obj is None: return self._get_obj_does_not_exist_redirect( @@ -2115,6 +2110,9 @@ def _changeform_view(self, request, object_id, form_url, extra_context): return response return HttpResponseRedirect(request.get_full_path()) + if not add and not self.has_change_permission(request, obj): + raise PermissionDenied + form = ModelForm(request.POST, request.FILES, instance=obj) formsets, inline_instances = self._create_formsets( request, diff --git a/tests/admin_views/test_actions.py b/tests/admin_views/test_actions.py index b723f76914ff..dbed3a33b6f0 100644 --- a/tests/admin_views/test_actions.py +++ b/tests/admin_views/test_actions.py @@ -514,7 +514,10 @@ def test_popup_template_escaping(self): self.assertIn(r""obj\\"", output) -@override_settings(ROOT_URLCONF="admin_views.urls") +@override_settings( + ROOT_URLCONF="admin_views.urls", + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, +) class AdminActionsPermissionTests(TestCase): @classmethod def setUpTestData(cls): @@ -568,6 +571,32 @@ def test_model_admin_no_delete_permission_externalsubscriber(self): ) self.assertEqual(response.status_code, 403) + def test_minimum_permission(self): + permission = Permission.objects.get(codename="view_externalsubscriber") + self.user.user_permissions.add(permission) + es = ExternalSubscriber.objects.create( + name="John Doe", email="john@example.org" + ) + + cl_url = reverse("admin:admin_views_externalsubscriber_changelist") + cl_post = {ACTION_CHECKBOX_NAME: [es.pk], "action": "external_mail"} + cf_url = reverse("admin:admin_views_externalsubscriber_change", args=[es.pk]) + cf_post = {ACTION_CHECKBOX_NAME: [es.pk], "CHANGE_FORM-action": "external_mail"} + + for url, post_data in [(cl_url, cl_post), (cf_url, cf_post)]: + mail.outbox = [] + with self.subTest(url=url): + response = self.client.get(url) + self.assertContains( + response, + '", + html=True, + ) + response = self.client.post(url, post_data) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(response.status_code, 302) + @override_settings( ROOT_URLCONF="admin_views.urls",