Skip to content

Commit 60ffa95

Browse files
[5.2.x] Fixed CVE-2026-4277 -- Checked add permissions in GenericInlineModelAdmin.
Edit permissions were still checked as part of ordinary form validation, but because GenericInlineModelAdmin overrides get_formset(), it lacked InlineModelAdmin's dynamic DeleteProtectedModelForm.has_changed() logic for checking permissions server-side, leaving the add case unaddressed. This change reimplements the relevant part of InlineModelAdmin.get_formset(). Thanks N05ec@LZU-DSLab for the report, and Natalia Bidart, Markus Holtermann, and Simon Charette for reviews. Backport of ef8b25d from main.
1 parent 1cc2a76 commit 60ffa95

4 files changed

Lines changed: 143 additions & 10 deletions

File tree

django/contrib/contenttypes/admin.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,21 @@ def get_formset(self, request, obj=None, **kwargs):
127127
**kwargs,
128128
}
129129

130+
base_model_form = defaults["form"]
131+
can_change = self.has_change_permission(request, obj) if request else True
132+
can_add = self.has_add_permission(request, obj) if request else True
133+
134+
class PermissionProtectedModelForm(base_model_form):
135+
def has_changed(self):
136+
# Protect against unauthorized edits.
137+
if not can_change and not self.instance._state.adding:
138+
return False
139+
if not can_add and self.instance._state.adding:
140+
return False
141+
return super().has_changed()
142+
143+
defaults["form"] = PermissionProtectedModelForm
144+
130145
if defaults["fields"] is None and not modelform_defines_fields(
131146
defaults["form"]
132147
):

docs/releases/4.2.30.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,13 @@ behavior of :pypi:`Daphne <daphne>`, the reference server for ASGI.
2626

2727
This issue has severity "low" according to the :ref:`Django security policy
2828
<security-disclosure>`.
29+
30+
CVE-2026-4277: Privilege abuse in ``GenericInlineModelAdmin``
31+
=============================================================
32+
33+
Add permissions on inline model instances were not validated on submission of
34+
forged ``POST`` data in
35+
:class:`~django.contrib.contenttypes.admin.GenericInlineModelAdmin`.
36+
37+
This issue has severity "low" according to the :ref:`Django security policy
38+
<security-disclosure>`.

docs/releases/5.2.13.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,13 @@ behavior of :pypi:`Daphne <daphne>`, the reference server for ASGI.
2626

2727
This issue has severity "low" according to the :ref:`Django security policy
2828
<security-disclosure>`.
29+
30+
CVE-2026-4277: Privilege abuse in ``GenericInlineModelAdmin``
31+
=============================================================
32+
33+
Add permissions on inline model instances were not validated on submission of
34+
forged ``POST`` data in
35+
:class:`~django.contrib.contenttypes.admin.GenericInlineModelAdmin`.
36+
37+
This issue has severity "low" according to the :ref:`Django security policy
38+
<security-disclosure>`.

tests/generic_inline_admin/tests.py

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django.contrib import admin
22
from django.contrib.admin.sites import AdminSite
3-
from django.contrib.auth.models import User
3+
from django.contrib.auth.models import Permission, User
44
from django.contrib.contenttypes.admin import GenericTabularInline
55
from django.contrib.contenttypes.models import ContentType
66
from django.forms.formsets import DEFAULT_MAX_NUM
@@ -15,9 +15,9 @@
1515
from django.urls import reverse
1616
from django.utils.deprecation import RemovedInDjango60Warning
1717

18-
from .admin import MediaInline, MediaPermanentInline
18+
from .admin import MediaInline, MediaPermanentInline, PhoneNumberInline
1919
from .admin import site as admin_site
20-
from .models import Category, Episode, EpisodePermanent, Media, PhoneNumber
20+
from .models import Category, Contact, Episode, EpisodePermanent, Media, PhoneNumber
2121

2222

2323
class TestDataMixin:
@@ -304,13 +304,102 @@ def test_delete(self):
304304

305305

306306
@override_settings(ROOT_URLCONF="generic_inline_admin.urls")
307-
class NoInlineDeletionTest(SimpleTestCase):
308-
@ignore_warnings(category=RemovedInDjango60Warning)
309-
def test_no_deletion(self):
310-
inline = MediaPermanentInline(EpisodePermanent, admin_site)
311-
fake_request = object()
312-
formset = inline.get_formset(fake_request)
313-
self.assertFalse(formset.can_delete)
307+
class GenericInlineAdminPermissionsTest(TestCase):
308+
factory = RequestFactory()
309+
310+
@classmethod
311+
def setUpTestData(cls):
312+
cls.user = User(username="admin", is_staff=True, is_active=True)
313+
cls.user.set_password("secret")
314+
cls.user.save()
315+
316+
# User always has all permissions on Contact (parent) model.
317+
# Permissions on the inlines vary per test.
318+
cls.contact_type = ContentType.objects.get_for_model(Contact)
319+
cls.user.user_permissions.add(
320+
*Permission.objects.filter(content_type=cls.contact_type)
321+
)
322+
323+
def test_add_inline_without_add_permission(self):
324+
self.client.force_login(self.user)
325+
inline_view_perm = Permission.objects.get(codename="view_phonenumber")
326+
self.user.user_permissions.add(inline_view_perm)
327+
328+
category_id = Category.objects.create(name="test").pk
329+
prefix = "generic_inline_admin-phonenumber-content_type-object_id"
330+
post_data = {
331+
"name": "Barbara",
332+
# inline data
333+
f"{prefix}-TOTAL_FORMS": "1",
334+
f"{prefix}-INITIAL_FORMS": "0",
335+
f"{prefix}-MIN_NUM_FORMS": "0",
336+
f"{prefix}-MAX_NUM_FORMS": "0",
337+
f"{prefix}-0-id": "",
338+
f"{prefix}-0-phone_number": "555-555-5555",
339+
f"{prefix}-0-category": str(category_id),
340+
}
341+
request = self.factory.get(reverse("admin:generic_inline_admin_contact_add"))
342+
request.user = self.user
343+
inline = PhoneNumberInline(Contact, AdminSite())
344+
FormSet = inline.get_formset(request)
345+
formset = FormSet(
346+
data=post_data, prefix=prefix, instance=Contact(name="Barbara")
347+
)
348+
349+
self.assertIs(formset.is_valid(), True)
350+
self.assertIs(formset.has_changed(), False)
351+
self.assertEqual(formset.save(commit=False), [])
352+
353+
def test_add_inline_with_change_permission_only(self):
354+
"""
355+
Forged new inline instances are ignored without add permissions, but
356+
but edits still work with edit permissions.
357+
"""
358+
self.client.force_login(self.user)
359+
inline_perms = Permission.objects.filter(
360+
codename__in=("view_phonenumber", "change_phonenumber")
361+
)
362+
self.user.user_permissions.add(*inline_perms)
363+
364+
category_id = Category.objects.create(name="test").pk
365+
contact = Contact.objects.create(name="Barbara")
366+
existing_number = PhoneNumber.objects.create(
367+
category_id=category_id,
368+
content_type=self.contact_type,
369+
object_id=contact.pk,
370+
phone_number="555-555-5555",
371+
)
372+
prefix = "generic_inline_admin-phonenumber-content_type-object_id"
373+
post_data = {
374+
"id": str(contact.pk),
375+
"name": "Barbara",
376+
# inline data
377+
f"{prefix}-TOTAL_FORMS": "2",
378+
f"{prefix}-INITIAL_FORMS": "1",
379+
f"{prefix}-MIN_NUM_FORMS": "0",
380+
f"{prefix}-MAX_NUM_FORMS": "0",
381+
# Attempt to edit the existing phone number value.
382+
f"{prefix}-0-id": str(existing_number.id),
383+
f"{prefix}-0-phone_number": "111-111-1111",
384+
f"{prefix}-0-category": str(category_id),
385+
# Attempt to forge a new phone number.
386+
f"{prefix}-1-id": "",
387+
f"{prefix}-1-phone_number": "666-666-6666",
388+
f"{prefix}-1-category": str(category_id),
389+
"_save": "Save",
390+
}
391+
request = self.factory.get(
392+
reverse("admin:generic_inline_admin_contact_change", args=[contact.pk])
393+
)
394+
request.user = self.user
395+
inline = PhoneNumberInline(Contact, AdminSite())
396+
FormSet = inline.get_formset(request)
397+
formset = FormSet(data=post_data, prefix=prefix, instance=contact)
398+
399+
self.assertIs(formset.is_valid(), True)
400+
self.assertIs(formset.has_changed(), True)
401+
# The edit succeeds; the add is ignored.
402+
self.assertEqual(formset.save(commit=False), [existing_number])
314403

315404

316405
class MockRequest:
@@ -326,6 +415,15 @@ def has_perm(self, perm, obj=None):
326415
request.user = MockSuperUser()
327416

328417

418+
@override_settings(ROOT_URLCONF="generic_inline_admin.urls")
419+
class NoInlineDeletionTest(SimpleTestCase):
420+
@ignore_warnings(category=RemovedInDjango60Warning)
421+
def test_no_deletion(self):
422+
inline = MediaPermanentInline(EpisodePermanent, admin_site)
423+
formset = inline.get_formset(request)
424+
self.assertFalse(formset.can_delete)
425+
426+
329427
@override_settings(ROOT_URLCONF="generic_inline_admin.urls")
330428
class GenericInlineModelAdminTest(SimpleTestCase):
331429
def setUp(self):

0 commit comments

Comments
 (0)