티스토리 수익 글 보기

티스토리 수익 글 보기

[6.0.x] Fixed CVE-2026-4277 — Checked add permissions in GenericInli… · django/django@08a752c · GitHub
Skip to content

Commit 08a752c

Browse files
[6.0.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 a623c39 commit 08a752c

5 files changed

Lines changed: 152 additions & 9 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>`.

docs/releases/6.0.4.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ behavior of :pypi:`Daphne <daphne>`, the reference server for ASGI.
2727
This issue has severity "low" according to the :ref:`Django security policy
2828
<security-disclosure>`.
2929

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>`.
39+
3040
Bugfixes
3141
========
3242

tests/generic_inline_admin/tests.py

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
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
77
from django.forms.models import ModelForm
88
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
99
from django.urls import reverse
1010

11-
from .admin import MediaInline, MediaPermanentInline
11+
from .admin import MediaInline, MediaPermanentInline, PhoneNumberInline
1212
from .admin import site as admin_site
13-
from .models import Category, Episode, EpisodePermanent, Media, PhoneNumber
13+
from .models import Category, Contact, Episode, EpisodePermanent, Media, PhoneNumber
1414

1515

1616
class TestDataMixin:
@@ -295,12 +295,102 @@ def test_delete(self):
295295

296296

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

305395

306396
class MockRequest:
@@ -316,6 +406,14 @@ def has_perm(self, perm, obj=None):
316406
request.user = MockSuperUser()
317407

318408

409+
@override_settings(ROOT_URLCONF="generic_inline_admin.urls")
410+
class NoInlineDeletionTest(SimpleTestCase):
411+
def test_no_deletion(self):
412+
inline = MediaPermanentInline(EpisodePermanent, admin_site)
413+
formset = inline.get_formset(request)
414+
self.assertFalse(formset.can_delete)
415+
416+
319417
@override_settings(ROOT_URLCONF="generic_inline_admin.urls")
320418
class GenericInlineModelAdminTest(SimpleTestCase):
321419
def setUp(self):

0 commit comments

Comments
 (0)