Skip to content

Commit 86ebf5d

Browse files
author
Ryan P Kilby
authored
Merge pull request #146 from rpkilby/secure-qs-default
Use more secure default for RelatedFilter.queryset
2 parents ebb03cf + 4126826 commit 86ebf5d

File tree

5 files changed

+84
-24
lines changed

5 files changed

+84
-24
lines changed

README.rst

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,15 @@ You can easily traverse multiple relationships when filtering by using ``Related
113113
114114
115115
class DepartmentFilter(filters.FilterSet):
116-
manager = filters.RelatedFilter(ManagerFilter, name='manager')
116+
manager = filters.RelatedFilter(ManagerFilter, name='manager', queryset=Manager.objects.all())
117117
118118
class Meta:
119119
model = Department
120120
fields = {'name': ['exact', 'in', 'startswith']}
121121
122122
123123
class CompanyFilter(filters.FilterSet):
124-
department = filters.RelatedFilter(DepartmentFilter, name='department')
124+
department = filters.RelatedFilter(DepartmentFilter, name='department', queryset=Department.objects.all())
125125
126126
class Meta:
127127
model = Company
@@ -140,13 +140,32 @@ Example filter calls:
140140
/api/companies?department__name=Accounting
141141
/api/companies?department__manager__name__startswith=Bob
142142
143+
``queryset`` callables
144+
""""""""""""""""""""""
145+
146+
Since ``RelatedFilter`` is a subclass of ``ModelChoiceFilter``, the ``queryset`` argument supports callable behavior.
147+
In the following example, the set of departments is restricted to those in the user's company.
148+
149+
.. code-block:: python
150+
151+
def departments(request):
152+
company = request.user.company
153+
return company.department_set.all()
154+
155+
class EmployeeFilter(filters.FilterSet):
156+
department = filters.RelatedFilter(filterset=DepartmentFilter, queryset=departments)
157+
...
158+
159+
Recursive relationships
160+
"""""""""""""""""""""""
161+
143162
Recursive relations are also supported. It may be necessary to specify the full module path.
144163
145164
.. code-block:: python
146165
147166
class PersonFilter(filters.FilterSet):
148167
name = filters.AllLookupsFilter(name='name')
149-
best_friend = filters.RelatedFilter('people.views.PersonFilter', name='best_friend')
168+
best_friend = filters.RelatedFilter('people.views.PersonFilter', name='best_friend', queryset=Person.objects.all())
150169
151170
class Meta:
152171
model = Person
@@ -185,7 +204,7 @@ to all filter classes. It incorporates some of the implementation details of the
185204
return qs.filter(**{lookup_expr: isnull})
186205
187206
class AuthorFilter(filters.FilterSet):
188-
posts = filters.RelatedFilter('PostFilter')
207+
posts = filters.RelatedFilter('PostFilter', queryset=Post.objects.all())
189208
190209
class Meta:
191210
model = Author
@@ -285,15 +304,15 @@ You cannot combine ``AllLookupsFilter`` with ``RelatedFilter`` as the filter nam
285304
.. code-block:: python
286305
287306
class ProductFilter(filters.FilterSet):
288-
manufacturer = filters.RelatedFilter('ManufacturerFilter')
307+
manufacturer = filters.RelatedFilter('ManufacturerFilter', queryset=Manufacturer.objects.all())
289308
manufacturer = filters.AllLookupsFilter()
290309
291310
To work around this, you have the following options:
292311
293312
.. code-block:: python
294313
295314
class ProductFilter(filters.FilterSet):
296-
manufacturer = filters.RelatedFilter('ManufacturerFilter')
315+
manufacturer = filters.RelatedFilter('ManufacturerFilter', queryset=Manufacturer.objects.all())
297316
298317
class Meta:
299318
model = Product
@@ -304,7 +323,7 @@ To work around this, you have the following options:
304323
# or
305324
306325
class ProductFilter(filters.FilterSet):
307-
manufacturer = filters.RelatedFilter('ManufacturerFilter', lookups='__all__') # `lookups` also accepts a list
326+
manufacturer = filters.RelatedFilter('ManufacturerFilter', queryset=Manufacturer.objects.all(), lookups='__all__') # `lookups` also accepts a list
308327
309328
class Meta:
310329
model = Product
@@ -326,15 +345,15 @@ and ``FilterSet``s from either package are compatible with the other's DRF backe
326345
...
327346
328347
class DRFFilter(rest_framework_filters.FilterSet):
329-
vanilla = rest_framework_filters.RelatedFilter(filterset=VanillaFilter)
348+
vanilla = rest_framework_filters.RelatedFilter(filterset=VanillaFilter, queryset=...)
330349
331350
332351
# invalid
333352
class DRFFilter(rest_framework_filters.FilterSet):
334353
...
335354
336355
class VanillaFilter(django_filters.FilterSet):
337-
drf = rest_framework_filters.RelatedFilter(filterset=DRFFilter)
356+
drf = rest_framework_filters.RelatedFilter(filterset=DRFFilter, queryset=...)
338357
339358
340359
Caveats & Limitations
@@ -380,6 +399,22 @@ The recommended solutions are to either:
380399
?publish_date__range=2016-01-01,2016-02-01
381400
382401
402+
Migrating to 1.0
403+
----------------
404+
405+
``RelatedFilter.queryset`` now required
406+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
407+
408+
The related filterset's model is no longer used to provide the default value for ``RelatedFilter.queryset``. This
409+
change reduces the chance of unintentionally exposing data in the rendered filter forms. You must now explicitly
410+
provide the ``queryset`` argument, or override the ``get_queryset()`` method (see `queryset callables`_).
411+
412+
413+
``get_filters()`` renamed to ``expand_filters()``
414+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
415+
416+
django-filter has add a ``get_filters()`` classmethod to it's API, so this method has been renamed.
417+
383418
License
384419
-------
385420
Copyright (c) 2013-2015 Philip Neustrom <philipn@gmail.com>,

rest_framework_filters/filters.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ def fset(self, value):
3838

3939
def get_queryset(self, request):
4040
queryset = super(RelatedFilter, self).get_queryset(request)
41-
if queryset is not None:
42-
return queryset
43-
return self.filterset._meta.model._default_manager.all()
41+
assert queryset is not None, \
42+
"Expected `.get_queryset()` to return a `QuerySet`, but got `None`."
43+
return queryset
4444

4545

4646
class AllLookupsFilter(AutoFilter):

tests/test_filtering.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414

1515
from .testapp.filters import (
16+
UserFilter,
1617
PersonFilter,
1718
PostFilter,
1819
BlogPostFilter,
@@ -326,6 +327,28 @@ def test_related_filters_caching(self):
326327

327328
self.assertEqual(len(filters), 1)
328329

330+
def test_relatedfilter_queryset_required(self):
331+
# Use a secure default queryset. Previous behavior was to use the default model
332+
# manager's `all()`, however this has the side effect of exposing related data.
333+
# The default behavior should not expose information, which requires users to
334+
# explicitly set the `queryset` argument.
335+
class NoteFilter(FilterSet):
336+
title = filters.CharFilter(name='title')
337+
author = filters.RelatedFilter(UserFilter, name='author')
338+
339+
class Meta:
340+
model = Note
341+
fields = []
342+
343+
GET = {'author': User.objects.get(username='user2').pk}
344+
f = NoteFilter(GET, queryset=Note.objects.all())
345+
346+
with self.assertRaises(AssertionError) as excinfo:
347+
f.qs
348+
349+
msg = str(excinfo.exception)
350+
self.assertEqual("Expected `.get_queryset()` to return a `QuerySet`, but got `None`.", msg)
351+
329352

330353
class MiscTests(TestCase):
331354
def test_multiwidget_incompatibility(self):

tests/test_performance.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import print_function
22

33
import argparse
4-
from timeit import timeit
4+
from timeit import repeat
55

66
from django.test import TestCase, Client, override_settings
77

@@ -34,14 +34,16 @@ def test_sanity(self):
3434
def test_response_time(self):
3535
data = {'author__username': 'bob'}
3636

37-
df_time = timeit(lambda: self.client.get('/df-notes/', data), number=1000)
38-
drf_time = timeit(lambda: self.client.get('/drf-notes/', data), number=1000)
37+
df_time = min(repeat(lambda: self.client.get('/df-notes/', data), number=200, repeat=5))
38+
drf_time = min(repeat(lambda: self.client.get('/drf-notes/', data), number=200, repeat=5))
39+
diff = (drf_time - df_time) / df_time * 100.0
3940

4041
if args.verbosity >= 2:
4142
print('\n' + '-' * 32)
4243
print('Response time performance')
4344
print('django-filter time:\t%.4fs' % df_time)
4445
print('drf-filters time:\t%.4fs' % drf_time)
46+
print('performance diff:\t%+.2f%% ' % diff)
4547
print('-' * 32)
4648

4749
self.assertTrue(drf_time < (df_time * self.threshold))

tests/testapp/filters.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class Meta:
4848

4949
class NoteFilterWithRelated(FilterSet):
5050
title = filters.CharFilter(name='title')
51-
author = RelatedFilter(UserFilter, name='author')
51+
author = RelatedFilter(UserFilter, name='author', queryset=User.objects.all())
5252

5353
class Meta:
5454
model = Note
@@ -57,7 +57,7 @@ class Meta:
5757

5858
class NoteFilterWithRelatedAll(FilterSet):
5959
title = filters.CharFilter(name='title')
60-
author = RelatedFilter(UserFilterWithAll, name='author')
60+
author = RelatedFilter(UserFilterWithAll, name='author', queryset=User.objects.all())
6161

6262
class Meta:
6363
model = Note
@@ -66,7 +66,7 @@ class Meta:
6666

6767
class NoteFilterWithRelatedAllDifferentFilterName(FilterSet):
6868
title = filters.CharFilter(name='title')
69-
writer = RelatedFilter(UserFilterWithAll, name='author')
69+
writer = RelatedFilter(UserFilterWithAll, name='author', queryset=User.objects.all())
7070

7171
class Meta:
7272
model = Note
@@ -75,7 +75,7 @@ class Meta:
7575

7676
class PostFilter(FilterSet):
7777
# Used for Related filter and Filter.method regression tests
78-
note = RelatedFilter(NoteFilterWithRelatedAll, name='note')
78+
note = RelatedFilter(NoteFilterWithRelatedAll, name='note', queryset=Note.objects.all())
7979
date_published = filters.AllLookupsFilter()
8080
is_published = filters.BooleanFilter(name='date_published', method='filter_is_published')
8181

@@ -96,7 +96,7 @@ def filter_is_published(self, qs, name, value):
9696

9797
class CoverFilterWithRelatedMethodFilter(FilterSet):
9898
comment = filters.CharFilter(name='comment')
99-
post = RelatedFilter(PostFilter, name='post')
99+
post = RelatedFilter(PostFilter, name='post', queryset=Post.objects.all())
100100

101101
class Meta:
102102
model = Cover
@@ -105,7 +105,7 @@ class Meta:
105105

106106
class CoverFilterWithRelated(FilterSet):
107107
comment = filters.CharFilter(name='comment')
108-
post = RelatedFilter(PostFilter, name='post')
108+
post = RelatedFilter(PostFilter, name='post', queryset=Post.objects.all())
109109

110110
class Meta:
111111
model = Cover
@@ -114,7 +114,7 @@ class Meta:
114114

115115
class PageFilterWithRelated(FilterSet):
116116
title = filters.CharFilter(name='title')
117-
previous_page = RelatedFilter(PostFilter, name='previous_page')
117+
previous_page = RelatedFilter(PostFilter, name='previous_page', queryset=Post.objects.all())
118118

119119
class Meta:
120120
model = Page
@@ -131,7 +131,7 @@ class Meta:
131131

132132
class BlogPostFilter(FilterSet):
133133
title = filters.CharFilter(name='title')
134-
tags = RelatedFilter(TagFilter, name='tags')
134+
tags = RelatedFilter(TagFilter, name='tags', queryset=Tag.objects.all())
135135

136136
class Meta:
137137
model = BlogPost
@@ -186,7 +186,7 @@ class Meta:
186186

187187
class PersonFilter(FilterSet):
188188
name = AllLookupsFilter(name='name')
189-
best_friend = RelatedFilter('tests.testapp.filters.PersonFilter', name='best_friend')
189+
best_friend = RelatedFilter('tests.testapp.filters.PersonFilter', name='best_friend', queryset=Person.objects.all())
190190

191191
class Meta:
192192
model = Person

0 commit comments

Comments
 (0)