Skip to content

Commit 4ea38d5

Browse files
committed
Fixed #37160 -- Made admin views raise PermissionDenied consistently.
The admin view_on_site and history views now return an HTTP 403 response when a staff user lacks view or change permission for the target model, consistent with the changeform and autocomplete views. Thanks Bence Nagy for the report.
1 parent 8a16207 commit 4ea38d5

4 files changed

Lines changed: 101 additions & 9 deletions

File tree

django/contrib/admin/options.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2563,14 +2563,14 @@ def history_view(self, request, object_id, extra_context=None):
25632563
# First check if the user can see this history.
25642564
model = self.model
25652565
obj = self.get_object(request, unquote(object_id))
2566+
if not self.has_view_or_change_permission(request, obj):
2567+
raise PermissionDenied
2568+
25662569
if obj is None:
25672570
return self._get_obj_does_not_exist_redirect(
25682571
request, model._meta, object_id
25692572
)
25702573

2571-
if not self.has_view_or_change_permission(request, obj):
2572-
raise PermissionDenied
2573-
25742574
# Then get the history for this object.
25752575
app_label = self.opts.app_label
25762576
action_list = (

django/contrib/admin/sites.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from django.contrib.admin.views.autocomplete import AutocompleteJsonView
1010
from django.contrib.auth import REDIRECT_FIELD_NAME
1111
from django.contrib.auth.decorators import login_not_required
12-
from django.core.exceptions import ImproperlyConfigured
12+
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
1313
from django.db.models.base import ModelBase
1414
from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
1515
from django.template.response import TemplateResponse
@@ -256,10 +256,6 @@ def inner(request, *args, **kwargs):
256256
return update_wrapper(inner, view)
257257

258258
def get_urls(self):
259-
# Since this module gets imported in the application's root package,
260-
# it cannot import models from other applications at the module level,
261-
# and django.contrib.contenttypes.views imports ContentType.
262-
from django.contrib.contenttypes import views as contenttype_views
263259
from django.urls import include, path, re_path
264260

265261
def wrap(view, cacheable=False):
@@ -290,7 +286,7 @@ def wrapper(*args, **kwargs):
290286
path("jsi18n/", wrap(self.i18n_javascript, cacheable=True), name="jsi18n"),
291287
path(
292288
"r/<path:content_type_id>/<path:object_id>/",
293-
wrap(contenttype_views.shortcut),
289+
wrap(self.shortcut_view),
294290
name="view_on_site",
295291
),
296292
]
@@ -451,6 +447,24 @@ def login(self, request, extra_context=None):
451447
def autocomplete_view(self, request):
452448
return AutocompleteJsonView.as_view(admin_site=self)(request)
453449

450+
def shortcut_view(self, request, content_type_id, object_id):
451+
from django.contrib.contenttypes import views as contenttype_views
452+
from django.contrib.contenttypes.models import ContentType
453+
454+
try:
455+
content_type = ContentType.objects.get_for_id(int(content_type_id))
456+
except (ContentType.DoesNotExist, ValueError):
457+
pass
458+
else:
459+
model_class = content_type.model_class()
460+
if (
461+
model_class is not None
462+
and self.is_registered(model_class)
463+
and not self.get_model_admin(model_class).has_view_permission(request)
464+
):
465+
raise PermissionDenied
466+
return contenttype_views.shortcut(request, content_type_id, object_id)
467+
454468
@no_append_slash
455469
def catch_all_view(self, request, url):
456470
if settings.APPEND_SLASH and not url.endswith("/"):

docs/releases/6.2.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,16 @@ backends.
266266

267267
* ...
268268

269+
:mod:`django.contrib.admin`
270+
---------------------------
271+
272+
* The admin ``view_on_site`` URL now consistently returns an HTTP 403 response
273+
when a staff user lacks view or change permission for the target model.
274+
275+
* The admin history view now checks permissions before object existence,
276+
consistently returning an HTTP 403 response for staff users without the view
277+
or change permission regardless of whether the object exists.
278+
269279
Miscellaneous
270280
-------------
271281

tests/admin_views/tests.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3380,6 +3380,24 @@ def test_history_view_bad_url(self):
33803380
["article with ID “foo” doesn’t exist. Perhaps it was deleted?"],
33813381
)
33823382

3383+
def test_history_view_without_permission_returns_403(self):
3384+
self.client.force_login(self.adduser)
3385+
for label, pk in [("existing", self.a1.pk), ("missing", 999999)]:
3386+
with self.subTest(pk=label):
3387+
response = self.client.get(
3388+
reverse("admin:admin_views_article_history", args=(pk,))
3389+
)
3390+
self.assertEqual(response.status_code, 403)
3391+
3392+
def test_history_view_with_view_or_change_permission_success(self):
3393+
for permission, user in [("view", self.viewuser), ("change", self.changeuser)]:
3394+
with self.subTest(permission=permission):
3395+
self.client.force_login(user)
3396+
response = self.client.get(
3397+
reverse("admin:admin_views_article_history", args=(self.a1.pk,))
3398+
)
3399+
self.assertEqual(response.status_code, 200)
3400+
33833401
def test_conditionally_show_add_section_link(self):
33843402
"""
33853403
The foreign key widget should only show the "add related" button if the
@@ -3527,6 +3545,56 @@ def test_shortcut_view_only_available_to_staff(self):
35273545
# Domain may depend on contrib.sites tests also run
35283546
self.assertRegex(response.url, "http://(testserver|example.com)/dummy/foo/")
35293547

3548+
def test_shortcut_view_without_permission_returns_403(self):
3549+
obj = ModelWithStringPrimaryKey.objects.create(string_pk="bar")
3550+
model_ctype = ContentType.objects.get_for_model(ModelWithStringPrimaryKey)
3551+
shortcut_url = reverse("admin:view_on_site", args=(model_ctype.pk, obj.pk))
3552+
# deleteuser has no view permission on ModelWithStringPrimaryKey.
3553+
self.client.force_login(self.deleteuser)
3554+
response = self.client.get(shortcut_url)
3555+
self.assertEqual(response.status_code, 403)
3556+
3557+
def test_shortcut_view_with_view_or_change_permission_success(self):
3558+
obj = ModelWithStringPrimaryKey.objects.create(string_pk="bar")
3559+
model_ctype = ContentType.objects.get_for_model(ModelWithStringPrimaryKey)
3560+
shortcut_url = reverse("admin:view_on_site", args=(model_ctype.pk, obj.pk))
3561+
opts = ModelWithStringPrimaryKey._meta
3562+
for permission in ["view", "change"]:
3563+
codename = get_permission_codename(permission, opts)
3564+
perm = get_perm(ModelWithStringPrimaryKey, codename)
3565+
with self.subTest(permission=permission):
3566+
self.viewuser.user_permissions.set([perm])
3567+
self.client.force_login(self.viewuser)
3568+
response = self.client.get(shortcut_url)
3569+
self.assertEqual(response.status_code, 302)
3570+
3571+
def test_shortcut_view_for_invalid_content_type_returns_404(self):
3572+
# An unknown or non-int content type id skips the permission check and
3573+
# falls back to the contenttypes shortcut view, which raises Http404.
3574+
self.client.force_login(self.deleteuser)
3575+
for content_type_id in [9999, "not-an-int", None]:
3576+
with self.subTest(content_type_id=content_type_id):
3577+
response = self.client.get(
3578+
reverse("admin:view_on_site", args=(content_type_id, 1))
3579+
)
3580+
self.assertEqual(response.status_code, 404)
3581+
3582+
def test_shortcut_view_does_not_repeat_content_type_query(self):
3583+
obj = ModelWithStringPrimaryKey.objects.create(string_pk="bar")
3584+
model_ctype = ContentType.objects.get_for_model(ModelWithStringPrimaryKey)
3585+
shortcut_url = reverse("admin:view_on_site", args=(model_ctype.pk, obj.pk))
3586+
self.client.force_login(self.superuser)
3587+
# A warmup request populates the ContentType and Site caches, so only
3588+
# relevant queries are measured. The 4 expected queries are:
3589+
# 1. Load the session.
3590+
# 2. Load the user.
3591+
# 3. Look up the content type.
3592+
# 4. Fetch the target instance.
3593+
self.client.get(shortcut_url)
3594+
with self.assertNumQueries(4):
3595+
response = self.client.get(shortcut_url)
3596+
self.assertEqual(response.status_code, 302)
3597+
35303598
def test_has_module_permission(self):
35313599
"""
35323600
has_module_permission() returns True for all users who

0 commit comments

Comments
 (0)