diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 85896bed7e2..795d20f96ac 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -1464,13 +1464,20 @@ class ModelAdmin(BaseModelAdmin):
)
def get_inline_formsets(self, request, formsets, inline_instances, obj=None):
+ # Edit permissions on parent model are required for editable inlines.
+ can_edit_parent = self.has_change_permission(request, obj) if obj else self.has_add_permission(request)
inline_admin_formsets = []
for inline, formset in zip(inline_instances, formsets):
fieldsets = list(inline.get_fieldsets(request, obj))
readonly = list(inline.get_readonly_fields(request, obj))
- has_add_permission = inline.has_add_permission(request, obj)
- has_change_permission = inline.has_change_permission(request, obj)
- has_delete_permission = inline.has_delete_permission(request, obj)
+ if can_edit_parent:
+ has_add_permission = inline.has_add_permission(request, obj)
+ has_change_permission = inline.has_change_permission(request, obj)
+ has_delete_permission = inline.has_delete_permission(request, obj)
+ else:
+ # Disable all edit-permissions, and overide formset settings.
+ has_add_permission = has_change_permission = has_delete_permission = False
+ formset.extra = formset.max_num = 0
has_view_permission = inline.has_view_permission(request, obj)
prepopulated = dict(inline.get_prepopulated_fields(request, obj))
inline_admin_formset = helpers.InlineAdminFormSet(
@@ -1535,8 +1542,12 @@ class ModelAdmin(BaseModelAdmin):
else:
obj = self.get_object(request, unquote(object_id), to_field)
- if not self.has_view_or_change_permission(request, obj):
- raise PermissionDenied
+ 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 obj is None:
return self._get_obj_does_not_exist_redirect(request, opts, object_id)
diff --git a/django/contrib/admin/templates/admin/edit_inline/stacked.html b/django/contrib/admin/templates/admin/edit_inline/stacked.html
index 8af4d54791d..d9c27b15780 100644
--- a/django/contrib/admin/templates/admin/edit_inline/stacked.html
+++ b/django/contrib/admin/templates/admin/edit_inline/stacked.html
@@ -12,7 +12,7 @@
{{ inline_admin_formset.opts.verbose_name|capfirst }}:{% if inline_admin_form.original %}{{ inline_admin_form.original }}{% if inline_admin_form.model_admin.show_change_link and inline_admin_form.model_admin.has_registered_model %} {% if inline_admin_formset.has_change_permission %}{% trans "Change" %}{% else %}{% trans "View" %}{% endif %}{% endif %}
{% else %}#{{ forloop.counter }}{% endif %}
{% if inline_admin_form.show_url %}{% trans "View on site" %}{% endif %}
- {% if inline_admin_formset.formset.can_delete and inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}{% endif %}
+ {% if inline_admin_formset.formset.can_delete and inline_admin_formset.has_delete_permission and inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}{% endif %}
{% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %}
{% for fieldset in inline_admin_form %}
diff --git a/django/contrib/admin/templates/admin/edit_inline/tabular.html b/django/contrib/admin/templates/admin/edit_inline/tabular.html
index 29a2af10890..261592af6ea 100644
--- a/django/contrib/admin/templates/admin/edit_inline/tabular.html
+++ b/django/contrib/admin/templates/admin/edit_inline/tabular.html
@@ -17,7 +17,7 @@
{% endif %}
{% endfor %}
- {% if inline_admin_formset.formset.can_delete %}
{% trans "Delete?" %}
{% endif %}
+ {% if inline_admin_formset.formset.can_delete and inline_admin_formset.has_delete_permission %}
{% trans "Delete?" %}
{% endif %}
@@ -63,7 +63,7 @@
{% endfor %}
{% endfor %}
{% endfor %}
- {% if inline_admin_formset.formset.can_delete %}
+ {% if inline_admin_formset.formset.can_delete and inline_admin_formset.has_delete_permission %}
{% if inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }}{% endif %}
{% endif %}
diff --git a/docs/releases/2.1.15.txt b/docs/releases/2.1.15.txt
index 8d13bb281f1..29764f986ee 100644
--- a/docs/releases/2.1.15.txt
+++ b/docs/releases/2.1.15.txt
@@ -4,7 +4,46 @@ Django 2.1.15 release notes
*Expected December 2, 2019*
-Django 2.1.15 fixes a data loss bug in 2.1.14.
+Django 2.1.15 fixes a security issue and a data loss bug in 2.1.14.
+
+CVE-2019-19118: Privilege escalation in the Django admin.
+=========================================================
+
+Since Django 2.1, a Django model admin displaying a parent model with related
+model inlines, where the user has view-only permissions to a parent model but
+edit permissions to the inline model, would display a read-only view of the
+parent model but editable forms for the inline.
+
+Submitting these forms would not allow direct edits to the parent model, but
+would trigger the parent model's ``save()`` method, and cause pre and post-save
+signal handlers to be invoked. This is a privilege escalation as a user who
+lacks permission to edit a model should not be able to trigger its save-related
+signals.
+
+To resolve this issue, the permission handling code of the Django admin
+interface has been changed. Now, if a user has only the "view" permission for a
+parent model, the entire displayed form will not be editable, even if the user
+has permission to edit models included in inlines.
+
+This is a backwards-incompatible change, and the Django security team is aware
+that some users of Django were depending on the ability to allow editing of
+inlines in the admin form of an otherwise view-only parent model.
+
+Given the complexity of the Django admin, and in-particular the permissions
+related checks, it is the view of the Django security team that this change was
+necessary: that it is not currently feasible to maintain the existing behavior
+whilst escaping the potential privilege escalation in a way that would avoid a
+recurrence of similar issues in the future, and that would be compatible with
+Django's *safe by default* philosophy.
+
+For the time being, developers whose applications are affected by this change
+should replace the use of inlines in read-only parents with custom forms and
+views that explicitly implement the desired functionality. In the longer term,
+adding a documented, supported, and properly-tested mechanism for
+partially-editable multi-model forms to the admin interface may occur in Django
+itself.
+
+Thank you to Shen Ying for reporting this issue.
Bugfixes
========
diff --git a/docs/releases/2.2.8.txt b/docs/releases/2.2.8.txt
index 3c5eb5c754d..2c4a6f9ec1f 100644
--- a/docs/releases/2.2.8.txt
+++ b/docs/releases/2.2.8.txt
@@ -4,8 +4,47 @@ Django 2.2.8 release notes
*Expected December 2, 2019*
-Django 2.2.8 fixes several bugs in 2.2.7 and adds compatibility with Python
-3.8.
+Django 2.2.8 fixes a security issue, several bugs in 2.2.7, and adds
+compatibility with Python 3.8.
+
+CVE-2019-19118: Privilege escalation in the Django admin.
+=========================================================
+
+Since Django 2.1, a Django model admin displaying a parent model with related
+model inlines, where the user has view-only permissions to a parent model but
+edit permissions to the inline model, would display a read-only view of the
+parent model but editable forms for the inline.
+
+Submitting these forms would not allow direct edits to the parent model, but
+would trigger the parent model's ``save()`` method, and cause pre and post-save
+signal handlers to be invoked. This is a privilege escalation as a user who
+lacks permission to edit a model should not be able to trigger its save-related
+signals.
+
+To resolve this issue, the permission handling code of the Django admin
+interface has been changed. Now, if a user has only the "view" permission for a
+parent model, the entire displayed form will not be editable, even if the user
+has permission to edit models included in inlines.
+
+This is a backwards-incompatible change, and the Django security team is aware
+that some users of Django were depending on the ability to allow editing of
+inlines in the admin form of an otherwise view-only parent model.
+
+Given the complexity of the Django admin, and in-particular the permissions
+related checks, it is the view of the Django security team that this change was
+necessary: that it is not currently feasible to maintain the existing behavior
+whilst escaping the potential privilege escalation in a way that would avoid a
+recurrence of similar issues in the future, and that would be compatible with
+Django's *safe by default* philosophy.
+
+For the time being, developers whose applications are affected by this change
+should replace the use of inlines in read-only parents with custom forms and
+views that explicitly implement the desired functionality. In the longer term,
+adding a documented, supported, and properly-tested mechanism for
+partially-editable multi-model forms to the admin interface may occur in Django
+itself.
+
+Thank you to Shen Ying for reporting this issue.
Bugfixes
========
diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py
index fa005ad17b0..eb276e56d1f 100644
--- a/tests/admin_inlines/tests.py
+++ b/tests/admin_inlines/tests.py
@@ -1,3 +1,5 @@
+from selenium.common.exceptions import NoSuchElementException
+
from django.contrib.admin import ModelAdmin, TabularInline
from django.contrib.admin.helpers import InlineAdminForm
from django.contrib.admin.tests import AdminSeleniumTestCase
@@ -862,6 +864,98 @@ class TestInlinePermissions(TestCase):
)
+@override_settings(ROOT_URLCONF='admin_inlines.urls')
+class TestReadOnlyChangeViewInlinePermissions(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = User.objects.create_user('testing', password='password', is_staff=True)
+ cls.user.user_permissions.add(
+ Permission.objects.get(codename='view_poll', content_type=ContentType.objects.get_for_model(Poll))
+ )
+ cls.user.user_permissions.add(
+ *Permission.objects.filter(
+ codename__endswith="question", content_type=ContentType.objects.get_for_model(Question)
+ ).values_list('pk', flat=True)
+ )
+
+ cls.poll = Poll.objects.create(name="Survey")
+ cls.add_url = reverse('admin:admin_inlines_poll_add')
+ cls.change_url = reverse('admin:admin_inlines_poll_change', args=(cls.poll.id,))
+
+ def setUp(self):
+ self.client.force_login(self.user)
+
+ def test_add_url_not_allowed(self):
+ response = self.client.get(self.add_url)
+ self.assertEqual(response.status_code, 403)
+
+ response = self.client.post(self.add_url, {})
+ self.assertEqual(response.status_code, 403)
+
+ def test_post_to_change_url_not_allowed(self):
+ response = self.client.post(self.change_url, {})
+ self.assertEqual(response.status_code, 403)
+
+ def test_get_to_change_url_is_allowed(self):
+ response = self.client.get(self.change_url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_main_model_is_rendered_as_read_only(self):
+ response = self.client.get(self.change_url)
+ self.assertContains(
+ response,
+ '