Nautobot dynamic-group-members doesn't enforce permission restrictions on member objects
Description
Nautobot is a Network Source of Truth and Network Automation Platform. A user with permissions to view Dynamic Group records (extras.view_dynamicgroup permission) can use the Dynamic Group detail UI view (/extras/dynamic-groups//) and/or the members REST API view (/api/extras/dynamic-groups//members/) to list the objects that are members of a given Dynamic Group. In versions of Nautobot between 1.3.0 (where the Dynamic Groups feature was added) and 1.6.22 inclusive, and 2.0.0 through 2.2.4 inclusive, Nautobot fails to restrict these listings based on the member object permissions - for example a Dynamic Group of Device objects will list all Devices that it contains, regardless of the user's dcim.view_device permissions or lack thereof. This issue has been fixed in Nautobot versions 1.6.23 and 2.2.5. Users are advised to upgrade. This vulnerability can be partially mitigated by removing extras.view_dynamicgroup permission from users however a full fix will require upgrading.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Nautobot Dynamic Groups UI and API list member objects without checking user permissions on those objects.
Root
Cause
Nautobot is a Network Source of Truth and Network Automation Platform. The Dynamic Groups feature, introduced in version 1.3.0, allows users with the extras.view_dynamicgroup permission to view group details and list group members via the UI (/extras/dynamic-groups//) or REST API (/api/extras/dynamic-groups//members/). However, in versions 1.3.0 through 1.6.22 and 2.0.0 through 2.2.4, Nautobot fails to enforce the appropriate object-level permissions (e.g., dcim.view_device) on the member objects themselves. This means a user who can view a Dynamic Group of Devices will see all Devices in that group, even if they lack dcim.view_device permission [1].
Attack
Surface and Exploitation
The attack surface is the Dynamic Group detail view and the members API endpoint. To exploit this, an attacker must have a Nautobot account with the extras.view_dynamicgroup permission. No additional authentication or network position is required beyond that. The vulnerability is a straightforward authorization bypass: the platform checks the user's permission to view the Dynamic Group but does not check permissions on the returned member objects. An attacker can simply navigate to the UI or call the API to enumerate all members of any Dynamic Group they can see [1].
Impact
An attacker can list all objects that belong to any Dynamic Group they have visibility of, regardless of their permissions on those object types. For example, a user with no dcim.view_device permission could see all devices in a group, potentially exposing sensitive inventory data. This affects any object type that can be part of a Dynamic Group, such as devices, circuits, or prefixes, and could lead to unauthorized disclosure of network topology or asset information [1].
Mitigation
This issue is fixed in Nautobot versions 1.6.23 and 2.2.5 [1][2][3][4]. The fix adds permission checks ensuring that the member listing only returns objects the user is authorized to view. Users are advised to upgrade. A partial workaround is to remove the extras.view_dynamicgroup permission from users, but this also prevents legitimate use of Dynamic Groups; upgrading is the complete solution [1].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
nautobotPyPI | >= 1.3.0, < 1.6.23 | 1.6.23 |
nautobotPyPI | >= 2.0.0, < 2.2.5 | 2.2.5 |
Affected products
2- nautobot/nautobotv5Range: >= 1.3.0, < 1.6.23
Patches
23a63aa1327f9[LTM] Dynamic groups permissions improvements (#5762)
7 files changed · +85 −11
changes/5762.security+1 −0 added@@ -0,0 +1 @@ +Fixed missing member object permission enforcement (e.g., enforce Device permissions for a Dynamic Group containing Devices) when viewing Dynamic Group member objects in the UI or REST API ([GHSA-qmjf-wc2h-6x3q](https://github.com/nautobot/nautobot/security/advisories/GHSA-qmjf-wc2h-6x3q)).
nautobot/extras/api/views.py+2 −2 modified@@ -330,13 +330,13 @@ class DynamicGroupViewSet(ModelViewSet, NotesViewSetMixin): # @extend_schema(methods=["get"], responses={200: member_response}) @action(detail=True, methods=["get"]) def members(self, request, pk, *args, **kwargs): - """List member objects of the same type as the `content_type` for this dynamic group.""" + """List the member objects of this dynamic group.""" instance = get_object_or_404(self.queryset, pk=pk) # Retrieve the serializer for the content_type and paginate the results member_model_class = instance.content_type.model_class() member_serializer_class = get_serializer_for_model(member_model_class) - members = self.paginate_queryset(instance.members) + members = self.paginate_queryset(instance.members.restrict(request.user, "view")) member_serializer = member_serializer_class(members, many=True, context={"request": request}) return self.get_paginated_response(member_serializer.data)
nautobot/extras/tests/test_api.py+23 −1 modified@@ -69,6 +69,7 @@ from nautobot.ipam.models import VLAN, VLANGroup from nautobot.users.models import ObjectPermission from nautobot.utilities.choices import ColorChoices +from nautobot.utilities.permissions import get_permission_for_model from nautobot.utilities.testing import APITestCase, APIViewTestCases from nautobot.utilities.testing.utils import disable_warnings from nautobot.utilities.utils import get_route_for_model, slugify_dashes_to_underscores @@ -752,13 +753,34 @@ class DynamicGroupTest(DynamicGroupTestMixin, APIViewTestCases.APIViewTestCase): def test_get_members(self): """Test that the `/members/` API endpoint returns what is expected.""" self.add_permissions("extras.view_dynamicgroup") - instance = DynamicGroup.objects.first() + instance = self.groups[0] + self.add_permissions(get_permission_for_model(instance.content_type.model_class(), "view")) member_count = instance.members.count() url = reverse("extras-api:dynamicgroup-members", kwargs={"pk": instance.pk}) response = self.client.get(url, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(member_count, len(response.json()["results"])) + def test_get_members_with_constrained_permission(self): + """Test that the `/members/` API endpoint enforces permissions on the member model.""" + self.add_permissions("extras.view_dynamicgroup") + instance = self.groups[0] + obj1 = instance.members.first() + obj_perm = ObjectPermission( + name="Test permission", + constraints={"pk__in": [obj1.pk]}, + actions=["view"], + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(instance.content_type) + + url = reverse("extras-api:dynamicgroup-members", kwargs={"pk": instance.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.json()["results"]), 1) + self.assertEqual(response.json()["results"][0]["id"], str(obj1.pk)) + class DynamicGroupMembershipTest(DynamicGroupTestMixin, APIViewTestCases.APIViewTestCase): model = DynamicGroupMembership
nautobot/extras/tests/test_dynamicgroups.py+1 −1 modified@@ -1002,7 +1002,7 @@ def all(self): group.members_cached self.assertEqual(mock_get_queryset.call_count, 1) - time.sleep(2) # Let the cache expire + time.sleep(3) # Let the cache expire group.members_cached self.assertEqual(mock_get_queryset.call_count, 2)
nautobot/extras/tests/test_views.py+48 −4 modified@@ -66,6 +66,7 @@ from nautobot.ipam.factory import VLANFactory from nautobot.ipam.models import VLAN, VLANGroup from nautobot.users.models import ObjectPermission +from nautobot.utilities.permissions import get_permission_for_model from nautobot.utilities.testing import ViewTestCases, TestCase, extract_page_body, extract_form_failures from nautobot.utilities.testing.utils import disable_warnings, post_data from nautobot.utilities.utils import slugify_dashes_to_underscores @@ -614,9 +615,11 @@ def setUpTestData(cls): content_type = ContentType.objects.get_for_model(Device) # DynamicGroup objects to test. - DynamicGroup.objects.create(name="DG 1", slug="dg-1", content_type=content_type) - DynamicGroup.objects.create(name="DG 2", slug="dg-2", content_type=content_type) - DynamicGroup.objects.create(name="DG 3", slug="dg-3", content_type=content_type) + cls.dynamic_groups = [ + DynamicGroup.objects.create(name="DG 1", slug="dg-1", content_type=content_type), + DynamicGroup.objects.create(name="DG 2", slug="dg-2", content_type=content_type), + DynamicGroup.objects.create(name="DG 3", slug="dg-3", content_type=content_type), + ] manufacturer = Manufacturer.objects.create(name="Manufacturer 1", slug="manufacturer-1") devicetype = DeviceType.objects.create(manufacturer=manufacturer, model="Device Type 1", slug="device-type-1") @@ -637,6 +640,38 @@ def setUpTestData(cls): "dynamic_group_memberships-MAX_NUM_FORMS": "1000", } + def test_get_object_with_permission(self): + instance = self._get_queryset().first() + # Add view permissions for the group's members: + self.add_permissions(get_permission_for_model(instance.content_type.model_class(), "view")) + + response = super().test_get_object_with_permission() + + response_body = extract_page_body(response.content.decode(response.charset)) + # Check that the "members" table in the detail view includes all appropriate member objects + for member in instance.members: + self.assertIn(str(member.pk), response_body) + + def test_get_object_with_constrained_permission(self): + instance = self._get_queryset().first() + # Add view permission for one of the group's members but not the others: + member1, member2 = instance.members[:2] + obj_perm = ObjectPermission( + name="Members permission", + constraints={"pk": member1.pk}, + actions=["view"], + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(instance.content_type) + + response = super().test_get_object_with_constrained_permission() + + response_body = extract_page_body(response.content.decode(response.charset)) + # Check that the "members" table in the detail view includes all permitted member objects + self.assertIn(str(member1.pk), response_body) + self.assertNotIn(str(member2.pk), response_body) + def test_get_object_dynamic_groups_anonymous(self): url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk}) self.client.logout() @@ -660,7 +695,6 @@ def test_get_object_dynamic_groups_with_permission(self): self.assertIn("DG 3", response_body, msg=response_body) def test_get_object_dynamic_groups_with_constrained_permission(self): - self.add_permissions("extras.view_dynamicgroup") obj_perm = ObjectPermission( name="View a device", constraints={"pk": Device.objects.first().pk}, @@ -669,12 +703,22 @@ def test_get_object_dynamic_groups_with_constrained_permission(self): obj_perm.save() obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(Device)) + obj_perm_2 = ObjectPermission( + name="View a Dynamic Group", + constraints={"pk": self.dynamic_groups[0].pk}, + actions=["view"], + ) + obj_perm_2.save() + obj_perm_2.users.add(self.user) + obj_perm_2.object_types.add(ContentType.objects.get_for_model(DynamicGroup)) url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk}) response = self.client.get(url) self.assertHttpStatus(response, 200) response_body = response.content.decode(response.charset) self.assertIn("DG 1", response_body, msg=response_body) + self.assertNotIn("DG 2", response_body, msg=response_body) + self.assertNotIn("DG 3", response_body, msg=response_body) url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.last().pk}) response = self.client.get(url)
nautobot/extras/views.py+4 −2 modified@@ -550,7 +550,7 @@ def get_extra_context(self, request, instance): if table_class is not None: # Members table (for display on Members nav tab) - members_table = table_class(instance.members, orderable=False) + members_table = table_class(instance.members.restrict(request.user, "view"), orderable=False) paginate = { "paginator_class": EnhancedPaginator, "per_page": get_paginate_count(request), @@ -722,7 +722,9 @@ def get(self, request, model, **kwargs): obj = get_object_or_404(model, **kwargs) # Gather all dynamic groups for this object (and its related objects) - dynamicsgroups_table = tables.DynamicGroupTable(data=obj.dynamic_groups_cached, orderable=False) + dynamicsgroups_table = tables.DynamicGroupTable( + data=obj.dynamic_groups_cached.restrict(request.user, "view"), orderable=False + ) # Apply the request context paginate = {
nautobot/utilities/testing/views.py+6 −1 modified@@ -209,6 +209,8 @@ def test_get_object_with_permission(self): escape(str(instance.cf.get(custom_field.name) or "")), response_body, msg=response_body ) + return response # for consumption by child test cases if desired + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] @@ -226,11 +228,14 @@ def test_get_object_with_constrained_permission(self): obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET to permitted object - self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200) + response = self.client.get(instance1.get_absolute_url()) + self.assertHttpStatus(response, 200) # Try GET to non-permitted object self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404) + return response # for consumption by child test cases if desired + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_has_advanced_tab(self): instance = self._get_queryset().first()
4d1ff2abe277Dynamic group permissions improvements (#5757)
6 files changed · +85 −11
changes/5757.security+1 −0 added@@ -0,0 +1 @@ +Fixed missing member object permission enforcement (e.g., enforce Device permissions for a Dynamic Group containing Devices) when viewing Dynamic Group member objects in the UI or REST API ([GHSA-qmjf-wc2h-6x3q](https://github.com/nautobot/nautobot/security/advisories/GHSA-qmjf-wc2h-6x3q)).
nautobot/core/testing/views.py+6 −1 modified@@ -213,6 +213,8 @@ def test_get_object_with_permission(self): escape(str(instance.cf.get(custom_field.key) or "")), response_body, msg=response_body ) + return response # for consumption by child test cases if desired + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] @@ -230,11 +232,14 @@ def test_get_object_with_constrained_permission(self): obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET to permitted object - self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200) + response = self.client.get(instance1.get_absolute_url()) + self.assertHttpStatus(response, 200) # Try GET to non-permitted object self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404) + return response # for consumption by child test cases if desired + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_has_advanced_tab(self): instance = self._get_queryset().first()
nautobot/extras/api/views.py+2 −2 modified@@ -304,13 +304,13 @@ class DynamicGroupViewSet(NotesViewSetMixin, ModelViewSet): # @extend_schema(methods=["get"], responses={200: member_response}) @action(detail=True, methods=["get"]) def members(self, request, pk, *args, **kwargs): - """List member objects of the same type as the `content_type` for this dynamic group.""" + """List the member objects of this dynamic group.""" instance = get_object_or_404(self.queryset, pk=pk) # Retrieve the serializer for the content_type and paginate the results member_model_class = instance.content_type.model_class() member_serializer_class = get_serializer_for_model(member_model_class) - members = self.paginate_queryset(instance.members) + members = self.paginate_queryset(instance.members.restrict(request.user, "view")) member_serializer = member_serializer_class(members, many=True, context={"request": request}) return self.get_paginated_response(member_serializer.data)
nautobot/extras/tests/test_api.py+24 −2 modified@@ -17,6 +17,7 @@ from nautobot.core.testing import APITestCase, APIViewTestCases from nautobot.core.testing.utils import disable_warnings from nautobot.core.utils.lookup import get_route_for_model +from nautobot.core.utils.permissions import get_permission_for_model from nautobot.dcim.models import ( Controller, Device, @@ -768,7 +769,7 @@ def setUpTestData(cls): # Then the DynamicGroups. cls.content_type = ContentType.objects.get_for_model(Device) - cls.groups = cls.groups = [ + cls.groups = [ DynamicGroup.objects.create( name="API DynamicGroup 1", content_type=cls.content_type, @@ -811,13 +812,34 @@ class DynamicGroupTest(DynamicGroupTestMixin, APIViewTestCases.APIViewTestCase): def test_get_members(self): """Test that the `/members/` API endpoint returns what is expected.""" self.add_permissions("extras.view_dynamicgroup") - instance = DynamicGroup.objects.first() + instance = self.groups[0] + self.add_permissions(get_permission_for_model(instance.content_type.model_class(), "view")) member_count = instance.members.count() url = reverse("extras-api:dynamicgroup-members", kwargs={"pk": instance.pk}) response = self.client.get(url, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(member_count, len(response.json()["results"])) + def test_get_members_with_constrained_permission(self): + """Test that the `/members/` API endpoint enforces permissions on the member model.""" + self.add_permissions("extras.view_dynamicgroup") + instance = self.groups[0] + obj1 = instance.members.first() + obj_perm = ObjectPermission( + name="Test permission", + constraints={"pk__in": [obj1.pk]}, + actions=["view"], + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(instance.content_type) + + url = reverse("extras-api:dynamicgroup-members", kwargs={"pk": instance.pk}) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.json()["results"]), 1) + self.assertEqual(response.json()["results"][0]["id"], str(obj1.pk)) + class DynamicGroupMembershipTest(DynamicGroupTestMixin, APIViewTestCases.APIViewTestCase): model = DynamicGroupMembership
nautobot/extras/tests/test_views.py+48 −4 modified@@ -17,6 +17,7 @@ from nautobot.core.models.fields import slugify_dashes_to_underscores from nautobot.core.testing import extract_form_failures, extract_page_body, TestCase, ViewTestCases from nautobot.core.testing.utils import disable_warnings, post_data +from nautobot.core.utils.permissions import get_permission_for_model from nautobot.dcim.models import ( ConsolePort, Controller, @@ -777,9 +778,11 @@ def setUpTestData(cls): content_type = ContentType.objects.get_for_model(Device) # DynamicGroup objects to test. - DynamicGroup.objects.create(name="DG 1", content_type=content_type) - DynamicGroup.objects.create(name="DG 2", content_type=content_type) - DynamicGroup.objects.create(name="DG 3", content_type=content_type) + cls.dynamic_groups = [ + DynamicGroup.objects.create(name="DG 1", content_type=content_type), + DynamicGroup.objects.create(name="DG 2", content_type=content_type), + DynamicGroup.objects.create(name="DG 3", content_type=content_type), + ] cls.form_data = { "name": "new_dynamic_group", @@ -792,6 +795,38 @@ def setUpTestData(cls): "dynamic_group_memberships-MAX_NUM_FORMS": "1000", } + def test_get_object_with_permission(self): + instance = self._get_queryset().first() + # Add view permissions for the group's members: + self.add_permissions(get_permission_for_model(instance.content_type.model_class(), "view")) + + response = super().test_get_object_with_permission() + + response_body = extract_page_body(response.content.decode(response.charset)) + # Check that the "members" table in the detail view includes all appropriate member objects + for member in instance.members: + self.assertIn(str(member.pk), response_body) + + def test_get_object_with_constrained_permission(self): + instance = self._get_queryset().first() + # Add view permission for one of the group's members but not the others: + member1, member2 = instance.members[:2] + obj_perm = ObjectPermission( + name="Members permission", + constraints={"pk": member1.pk}, + actions=["view"], + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(instance.content_type) + + response = super().test_get_object_with_constrained_permission() + + response_body = extract_page_body(response.content.decode(response.charset)) + # Check that the "members" table in the detail view includes all permitted member objects + self.assertIn(str(member1.pk), response_body) + self.assertNotIn(str(member2.pk), response_body) + def test_get_object_dynamic_groups_anonymous(self): url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk}) self.client.logout() @@ -815,7 +850,6 @@ def test_get_object_dynamic_groups_with_permission(self): self.assertIn("DG 3", response_body, msg=response_body) def test_get_object_dynamic_groups_with_constrained_permission(self): - self.add_permissions("extras.view_dynamicgroup") obj_perm = ObjectPermission( name="View a device", constraints={"pk": Device.objects.first().pk}, @@ -824,12 +858,22 @@ def test_get_object_dynamic_groups_with_constrained_permission(self): obj_perm.save() obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(Device)) + obj_perm_2 = ObjectPermission( + name="View a Dynamic Group", + constraints={"pk": self.dynamic_groups[0].pk}, + actions=["view"], + ) + obj_perm_2.save() + obj_perm_2.users.add(self.user) + obj_perm_2.object_types.add(ContentType.objects.get_for_model(DynamicGroup)) url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk}) response = self.client.get(url) self.assertHttpStatus(response, 200) response_body = response.content.decode(response.charset) self.assertIn("DG 1", response_body, msg=response_body) + self.assertNotIn("DG 2", response_body, msg=response_body) + self.assertNotIn("DG 3", response_body, msg=response_body) url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.last().pk}) response = self.client.get(url)
nautobot/extras/views.py+4 −2 modified@@ -704,7 +704,7 @@ def get_extra_context(self, request, instance): if table_class is not None: # Members table (for display on Members nav tab) - members_table = table_class(instance.members, orderable=False) + members_table = table_class(instance.members.restrict(request.user, "view"), orderable=False) paginate = { "paginator_class": EnhancedPaginator, "per_page": get_paginate_count(request), @@ -884,7 +884,9 @@ def get(self, request, model, **kwargs): obj = get_object_or_404(model, **kwargs) # Gather all dynamic groups for this object (and its related objects) - dynamicsgroups_table = tables.DynamicGroupTable(data=obj.dynamic_groups_cached, orderable=False) + dynamicsgroups_table = tables.DynamicGroupTable( + data=obj.dynamic_groups_cached.restrict(request.user, "view"), orderable=False + ) # Apply the request context paginate = {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-qmjf-wc2h-6x3qghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-36112ghsaADVISORY
- github.com/nautobot/nautobot/commit/3a63aa1327f943b2ac8452757ea2e4d403387ad6ghsaWEB
- github.com/nautobot/nautobot/commit/4d1ff2abe2775b0a6fb16e6d1d503a78226a6f8eghsaWEB
- github.com/nautobot/nautobot/pull/5757ghsax_refsource_MISCWEB
- github.com/nautobot/nautobot/pull/5762ghsax_refsource_MISCWEB
- github.com/nautobot/nautobot/security/advisories/GHSA-qmjf-wc2h-6x3qghsax_refsource_CONFIRMWEB
- github.com/pypa/advisory-database/tree/main/vulns/nautobot/PYSEC-2024-166.yamlghsaWEB
News mentions
0No linked articles in our index yet.