Jelajahi Sumber

Fixes #22439: Enforce view permissions for Custom Links (#22469)

Filter custom links by the current user's view permissions before rendering
them on object detail views and table columns.
Arthur Hanson 2 minggu lalu
induk
melakukan
626e1ef1f8

+ 2 - 1
netbox/extras/templatetags/custom_links.py

@@ -33,8 +33,9 @@ def custom_links(context, obj):
     """
     Render all applicable links for the given object.
     """
+    user = context['request'].user
     object_type = ObjectType.objects.get_for_model(obj)
-    custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
+    custom_links = CustomLink.objects.restrict(user, 'view').filter(object_types=object_type, enabled=True)
     if not custom_links:
         return ''
 

+ 75 - 0
netbox/extras/tests/test_templatetags.py

@@ -0,0 +1,75 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.context_processors import PermWrapper
+from django.test import RequestFactory, TestCase
+
+from core.models import ObjectType
+from dcim.models import Site
+from extras.models import CustomLink
+from extras.templatetags.custom_links import custom_links
+from users.models import ObjectPermission
+
+User = get_user_model()
+
+
+class CustomLinkTemplateTagTest(TestCase):
+    """
+    The custom_links template tag must honor object-level permissions on CustomLink (see #22439).
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+        cls.custom_link = CustomLink.objects.create(
+            name='Custom Link 1',
+            enabled=True,
+            weight=100,
+            new_window=False,
+            link_text='Link 1',
+            link_url='http://example.com/'
+        )
+        cls.custom_link.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+    def render(self, user):
+        request = RequestFactory().get('/')
+        request.user = user
+        context = {
+            'request': request,
+            'user': user,
+            'perms': PermWrapper(user),
+        }
+        return custom_links(context, self.site)
+
+    def test_no_permission_hides_link(self):
+        # A user without the view_customlink permission must not see the link.
+        user = User.objects.create_user(username='user1')
+        self.assertNotIn('Link 1', self.render(user))
+
+    def test_constrained_permission_hides_link(self):
+        # A user granted view_customlink via a constraint that excludes the link must not see it (#22439).
+        user = User.objects.create_user(username='user2')
+        permission = ObjectPermission.objects.create(
+            name='Constrained custom links',
+            actions=['view'],
+            constraints={'name': 'Some Other Link'}  # Does not match Custom Link 1
+        )
+        permission.object_types.set([ObjectType.objects.get_for_model(CustomLink)])
+        permission.users.set([user])
+
+        # Re-fetch to clear any cached permissions
+        user = User.objects.get(pk=user.pk)
+        self.assertNotIn('Link 1', self.render(user))
+
+    def test_permitted_link_is_shown(self):
+        # A user granted view_customlink covering the link must see it.
+        user = User.objects.create_user(username='user3')
+        permission = ObjectPermission.objects.create(
+            name='Custom links',
+            actions=['view'],
+            constraints={'name': 'Custom Link 1'}  # Matches Custom Link 1
+        )
+        permission.object_types.set([ObjectType.objects.get_for_model(CustomLink)])
+        permission.users.set([user])
+
+        # Re-fetch to clear any cached permissions
+        user = User.objects.get(pk=user.pk)
+        self.assertIn('Link 1', self.render(user))

+ 42 - 1
netbox/extras/tests/test_views.py

@@ -184,7 +184,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
 class CustomLinkRenderingTestCase(TestCase):
-    user_permissions = ['dcim.view_site']
+    user_permissions = ['dcim.view_site', 'extras.view_customlink']
 
     def test_view_object_with_custom_link(self):
         customlink = CustomLink(
@@ -203,6 +203,47 @@ class CustomLinkRenderingTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertIn(f'FOO {site.name} BAR', str(response.content))
 
+    def test_list_view_custom_link_column(self):
+        # A custom link column must render in the list view when the user can view the CustomLink.
+        customlink = CustomLink(
+            name='Test',
+            link_text='FOO {{ object.name }} BAR',
+            link_url='http://example.com/?site={{ object.slug }}',
+            new_window=False
+        )
+        customlink.save()
+        customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+        site = Site(name='Test Site', slug='test-site')
+        site.save()
+
+        # Custom link columns are hidden by default; explicitly include the column
+        response = self.client.get(f"{reverse('dcim:site_list')}?include_columns=cl_Test")
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(f'FOO {site.name} BAR', str(response.content))
+
+    def test_list_view_custom_link_column_hidden_without_permission(self):
+        # A custom link column must be excluded from the list view when the user cannot view the
+        # CustomLink, even if explicitly requested (#22439).
+        customlink = CustomLink(
+            name='Test',
+            link_text='FOO {{ object.name }} BAR',
+            link_url='http://example.com/?site={{ object.slug }}',
+            new_window=False
+        )
+        customlink.save()
+        customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+        site = Site(name='Test Site', slug='test-site')
+        site.save()
+
+        # Revoke permission to view custom links
+        self.remove_permissions('extras.view_customlink')
+
+        response = self.client.get(f"{reverse('dcim:site_list')}?include_columns=cl_Test")
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn(f'FOO {site.name} BAR', str(response.content))
+
 
 class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = SavedFilter

+ 32 - 0
netbox/netbox/tables/tables.py

@@ -287,6 +287,38 @@ class NetBoxTable(BaseTable):
 
         super().__init__(*args, extra_columns=extra_columns, **kwargs)
 
+    def configure(self, request):
+        # Remove custom link columns referencing CustomLinks the user cannot view (#22439).
+        # These columns are added for all enabled CustomLinks in __init__(), before the request
+        # (and thus the user) is known, so object-level permissions are enforced here instead.
+        self._restrict_customlink_columns(request.user)
+
+        super().configure(request)
+
+    def _restrict_customlink_columns(self, user):
+        """
+        Exclude any custom link columns which reference a CustomLink the user does not have
+        permission to view.
+        """
+        customlinks = {
+            name: column.column.customlink
+            for name, column in self.columns.iteritems()
+            if isinstance(column.column, columns.CustomLinkColumn)
+        }
+        if not customlinks:
+            return
+
+        permitted = set(
+            CustomLink.objects.restrict(user, 'view').filter(
+                pk__in=[cl.pk for cl in customlinks.values()]
+            ).values_list('pk', flat=True)
+        )
+        excluded = tuple(
+            name for name, customlink in customlinks.items() if customlink.pk not in permitted
+        )
+        if excluded:
+            self.exclude = (*self.exclude, *excluded)
+
     @cached_property
     def htmx_url(self):
         """