From b3c27b951d0f08c6d6a9d1802b858411bf800b5d Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 28 Jul 2026 03:51:14 -0500 Subject: [PATCH] Closes #22790: Enforce SavedFilter visibility when applied via filter/filter_id (#22791) BaseFilterSet resolved referenced SavedFilters without a visibility check, so a private (shared=False) filter owned by one user could have its parameters applied to another user's request. Restrict resolution to shared or owned filters via restrict_to_shared(), matching the visibility enforced on the UI, REST, and GraphQL SavedFilter surfaces. --- netbox/netbox/filtersets.py | 7 ++- netbox/utilities/tests/test_filters.py | 69 ++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 0211805e5..090fb9028 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -2,6 +2,7 @@ import json from copy import deepcopy import django_filters +from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import Q @@ -118,7 +119,11 @@ class BaseFilterSet(django_filters.FilterSet): except (ValueError, TypeError): pass - saved_filters = SavedFilter.objects.filter( + # Only apply SavedFilters the requesting user is permitted to see (#22790). Fall back to + # anonymous visibility (shared filters only) when no request is available. + request = kwargs.get('request') + user = request.user if request else AnonymousUser() + saved_filters = SavedFilter.objects.restrict_to_shared(user).filter( Q(slug__in=data.pop('filter', [])) | Q(pk__in=filter_ids) ) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 2319ae5a9..19189540c 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -1,8 +1,9 @@ import django_filters from django.conf import settings +from django.contrib.auth import get_user_model from django.db import models from django.http import QueryDict -from django.test import TestCase +from django.test import RequestFactory, TestCase from mptt.fields import TreeForeignKey from taggit.managers import TaggableManager @@ -38,6 +39,8 @@ from utilities.filters import ( ) from wireless.choices import WirelessRoleChoices +User = get_user_model() + class TreeNodeMultipleChoiceFilterTestCase(TestCase): @@ -761,22 +764,80 @@ class SavedFilterApplicationTestCase(TestCase): ) cls.saved_filter.object_types.set([ObjectType.objects.get_for_model(Site)]) + def setUp(self): + self.request = RequestFactory().get('/') + self.request.user = User.objects.create_user('testuser') + def test_filter_id_valid(self): # A referenced SavedFilter's parameters are applied to the queryset data = QueryDict(f'filter_id={self.saved_filter.pk}') - self.assertEqual(SiteFilterSet(data, Site.objects.all()).qs.count(), 1) + self.assertEqual(SiteFilterSet(data, Site.objects.all(), request=self.request).qs.count(), 1) def test_filter_id_nonexistent(self): # A non-existent (but valid integer) filter_id is ignored data = QueryDict('filter_id=999999') - self.assertEqual(SiteFilterSet(data, Site.objects.all()).qs.count(), 2) + self.assertEqual(SiteFilterSet(data, Site.objects.all(), request=self.request).qs.count(), 2) def test_filter_id_non_integer(self): # A non-integer filter_id is ignored rather than raising a ValueError (#22568) data = QueryDict('filter_id=abc') - self.assertEqual(SiteFilterSet(data, Site.objects.all()).qs.count(), 2) + self.assertEqual(SiteFilterSet(data, Site.objects.all(), request=self.request).qs.count(), 2) def test_filter_slug(self): # A referenced SavedFilter may also be applied by slug data = QueryDict('filter=active-sites') + self.assertEqual(SiteFilterSet(data, Site.objects.all(), request=self.request).qs.count(), 1) + + def test_private_filter_not_applied_for_other_user(self): + # A private SavedFilter owned by another user must not be applied (#22790) + owner = User.objects.create_user('owner') + private_filter = SavedFilter.objects.create( + name='Private', + slug='private', + user=owner, + shared=False, + parameters={'status': [SiteStatusChoices.STATUS_ACTIVE]}, + ) + private_filter.object_types.set([ObjectType.objects.get_for_model(Site)]) + + request = RequestFactory().get('/') + request.user = User.objects.create_user('other') + data = QueryDict('filter=private') + self.assertEqual(SiteFilterSet(data, Site.objects.all(), request=request).qs.count(), 2) + + def test_private_filter_applied_for_owner(self): + # The owner of a private SavedFilter can still apply it (#22790) + owner = User.objects.create_user('owner') + private_filter = SavedFilter.objects.create( + name='Private', + slug='private', + user=owner, + shared=False, + parameters={'status': [SiteStatusChoices.STATUS_ACTIVE]}, + ) + private_filter.object_types.set([ObjectType.objects.get_for_model(Site)]) + + request = RequestFactory().get('/') + request.user = owner + data = QueryDict('filter=private') + self.assertEqual(SiteFilterSet(data, Site.objects.all(), request=request).qs.count(), 1) + + def test_shared_filter_applied_without_request(self): + # Without a request, a shared SavedFilter is still applied (anonymous visibility) + data = QueryDict('filter=active-sites') self.assertEqual(SiteFilterSet(data, Site.objects.all()).qs.count(), 1) + + def test_private_filter_not_applied_without_request(self): + # Without a request, a private SavedFilter is not applied (#22790) + owner = User.objects.create_user('owner') + private_filter = SavedFilter.objects.create( + name='Private', + slug='private', + user=owner, + shared=False, + parameters={'status': [SiteStatusChoices.STATUS_ACTIVE]}, + ) + private_filter.object_types.set([ObjectType.objects.get_for_model(Site)]) + + data = QueryDict('filter=private') + self.assertEqual(SiteFilterSet(data, Site.objects.all()).qs.count(), 2)