From 850d4dd1ad5edb3ac442200cbd4390cb489aea3d Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Wed, 15 Apr 2026 23:37:38 +0200 Subject: [PATCH] fix(ui): Suppress unauthorized embedded object tables Add a `should_render()` hook to the `Panel` base class and override it in `ObjectsTablePanel` to check the requesting user's view permission for the panel's model. This prevents object detail pages from issuing HTMX requests for related tables (e.g. locations, devices, image attachments) that return 403 and disrupt the page. Fixes #21893 Co-authored-by: Jeremy Stretch --- netbox/dcim/tests/test_views.py | 22 ++++++++++++ netbox/netbox/tests/test_ui.py | 59 +++++++++++++++++++++++++++++++-- netbox/netbox/ui/panels.py | 25 +++++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 13faee4e2..35e411c12 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -194,6 +194,28 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + def test_get_object_with_only_site_view_permission_hides_unauthorized_embedded_panels(self): + site = self._get_queryset().first() + + obj_perm = ObjectPermission( + name='Test permission', + actions=['view'], + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + response = self.client.get(site.get_absolute_url()) + self.assertHttpStatus(response, 200) + + for panel, url in ( + ('locations', reverse('dcim:location_list')), + ('devices', reverse('dcim:device_list')), + ('image attachments', reverse('extras:imageattachment_list')), + ): + with self.subTest(panel=panel): + self.assertNotContains(response, url) + class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Location diff --git a/netbox/netbox/tests/test_ui.py b/netbox/netbox/tests/test_ui.py index cb76517c1..8a328793f 100644 --- a/netbox/netbox/tests/test_ui.py +++ b/netbox/netbox/tests/test_ui.py @@ -1,4 +1,4 @@ -from django.test import TestCase +from django.test import RequestFactory, TestCase from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices from circuits.models import ( @@ -8,9 +8,12 @@ from circuits.models import ( VirtualCircuitTermination, VirtualCircuitType, ) +from core.models import ObjectType from dcim.choices import InterfaceTypeChoices -from dcim.models import Interface +from dcim.models import Interface, Site from netbox.ui import attrs +from netbox.ui.panels import ObjectsTablePanel +from users.models import ObjectPermission, User from utilities.testing import create_test_device from vpn.choices import ( AuthenticationAlgorithmChoices, @@ -213,3 +216,55 @@ class RelatedObjectListAttrTest(TestCase): self.assertInHTML('
  • IKE Proposal 2
  • ', rendered) self.assertNotIn('IKE Proposal 3', rendered) self.assertIn('…', rendered) + + +class ObjectsTablePanelTest(TestCase): + """ + Verify that ObjectsTablePanel.should_render() hides the panel when + the requesting user lacks view permission for the panel's model. + """ + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(username='test_user', password='test_password') + + # Grant view permission only for Site + obj_perm = ObjectPermission.objects.create( + name='View sites only', + actions=['view'], + ) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Site)) + obj_perm.users.add(cls.user) + + def setUp(self): + self.factory = RequestFactory() + self.panel = ObjectsTablePanel(model='dcim.site') + self.panel_no_perm = ObjectsTablePanel(model='dcim.location') + + def _make_context(self, user=None): + if user is None: + return {} + request = self.factory.get('/') + request.user = user + return {'request': request} + + def test_should_render_without_request(self): + """ + Panel should render when no request is present in context. + """ + context = self.panel.get_context({}) + self.assertTrue(self.panel.should_render(context)) + + def test_should_render_with_permission(self): + """ + Panel should render when the user has view permission for the panel's model. + """ + context = self.panel.get_context(self._make_context(self.user)) + self.assertTrue(self.panel.should_render(context)) + + def test_should_not_render_without_permission(self): + """ + Panel should be hidden when the user lacks view permission for the panel's model. + """ + context = self.panel_no_perm.get_context(self._make_context(self.user)) + self.assertFalse(self.panel_no_perm.should_render(context)) diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index f58e98449..f1afa963b 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs from netbox.ui.actions import CopyContent from utilities.data import resolve_attr_path +from utilities.permissions import get_permission_for_model from utilities.querydict import dict_to_querydict from utilities.string import title from utilities.templatetags.plugins import _get_registered_content @@ -74,6 +75,15 @@ class Panel: 'panel_class': self.__class__.__name__, } + def should_render(self, context): + """ + Determines whether the panel should render on the page. (Default: True) + + Parameters: + context (dict): The panel's prepared context (the return value of get_context()) + """ + return True + def render(self, context): """ Render the panel as HTML. @@ -81,7 +91,10 @@ class Panel: Parameters: context (dict): The template context """ - return render_to_string(self.template_name, self.get_context(context)) + ctx = self.get_context(context) + if not self.should_render(ctx): + return '' + return render_to_string(self.template_name, ctx, request=ctx.get('request')) # @@ -314,6 +327,16 @@ class ObjectsTablePanel(Panel): 'url_params': dict_to_querydict(url_params), } + def should_render(self, context): + """ + Hide the panel if the user does not have view permission for the panel's model. + """ + request = context.get('request') + if request is None: + return True + + return request.user.has_perm(get_permission_for_model(self.model, 'view')) + class TemplatePanel(Panel): """