Repository: akvo/akvo-rsr

Issue:
Passing a malformed filter to REST endpoints silently fails and returns unfiltered results

```
http://localhost/rest/v1/project_update/?format=json&filter={%27project__partners__in%27:[1234]}
http://localhost/rest/v1/project_update/?format=json&filter={%27project__partners__in%27:[4239]
```
The second query will return project without the desired filter because of a single, missing `}`.

# Suggestion

Throw an exception when the param's value is invalid.

The relevant code can be found here: https://github.com/akvo/akvo-rsr/blob/master/akvo/rest/filters.py#L58


Discussion:

Resolving patch:
diff --git a/akvo/rest/filters.py b/akvo/rest/filters.py
--- a/akvo/rest/filters.py
+++ b/akvo/rest/filters.py
@@ -5,6 +5,7 @@
 # For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 
 import ast
+import logging
 
 from django.db.models import Q
 from django.core.exceptions import FieldError
@@ -12,6 +13,8 @@
 from rest_framework import filters
 from rest_framework.exceptions import APIException
 
+logger = logging.getLogger(__name__)
+
 
 class RSRGenericFilterBackend(filters.BaseFilterBackend):
 
@@ -47,26 +50,27 @@ def eval_query_value(request, key):
             :return: a python data type object, or None if literal_eval() fails
             """
             value = request.query_params.get(key, None)
-            try:
-                return ast.literal_eval(value)
-            except (ValueError, SyntaxError):
-                return None
+            return ast.literal_eval(value)
 
         qs_params = ['filter', 'exclude', 'select_related', 'prefetch_related']
 
         # evaluate each query string param, and apply the queryset method with the same name
-        for param in qs_params:
-            args_or_kwargs = eval_query_value(request, param)
-            if args_or_kwargs:
-                # filter and exclude are called with a dict kwarg, the _related methods with a list
-                try:
-                    if param in ['filter', 'exclude', ]:
-                        queryset = getattr(queryset, param)(**args_or_kwargs)
-                    else:
-                        queryset = getattr(queryset, param)(*args_or_kwargs)
-
-                except FieldError as e:
-                    raise APIException("Error in request: {}".format(e))
+        for param in set(request.query_params) & set(qs_params):
+            try:
+                args_or_kwargs = eval_query_value(request, param)
+            except (ValueError, SyntaxError):
+                logger.warning("Couldn't parse user param: %s", param, exc_info=True)
+                raise APIException(f"Query param {param} is invalid")
+
+            # filter and exclude are called with a dict kwarg, the _related methods with a list
+            try:
+                if param in ['filter', 'exclude', ]:
+                    queryset = getattr(queryset, param)(**args_or_kwargs)
+                else:
+                    queryset = getattr(queryset, param)(*args_or_kwargs)
+
+            except FieldError as e:
+                raise APIException("Error in request: {}".format(e))
 
         # support for Q expressions, limited to OR-concatenated filtering
         if request.query_params.get('q_filter1', None):


Test patch:
diff --git a/akvo/rest/tests/__init__.py b/akvo/rest/tests/__init__.py
new file mode 100644
diff --git a/akvo/rest/tests/test_filters.py b/akvo/rest/tests/test_filters.py
new file mode 100644
--- /dev/null
+++ b/akvo/rest/tests/test_filters.py
@@ -0,0 +1,40 @@
+from django.db import models
+from django.test import TestCase
+from rest_framework.exceptions import APIException
+from rest_framework.request import Request
+from rest_framework.reverse import reverse
+from rest_framework.test import APIRequestFactory
+
+from akvo.rest.filters import RSRGenericFilterBackend
+from akvo.rsr.models import ProjectUpdate
+
+
+class TestRSRGenericFilterBackend(TestCase):
+
+    def test_filter_queryset(self):
+        factory = APIRequestFactory()
+        request = Request(factory.get(
+            reverse("project_update-list", kwargs={"version": "v1"}),
+            data={
+                "filter": '{"project__partnerships__in":[43]}'
+            }
+        ))
+        backend = RSRGenericFilterBackend()
+        queryset = backend.filter_queryset(request, ProjectUpdate.objects.all(), None)
+        self.assertIsInstance(queryset, models.QuerySet)
+        self.assertEqual(queryset.model, ProjectUpdate)
+
+    def test_filter_queryset__bad_filter(self):
+        factory = APIRequestFactory()
+        request = Request(factory.get(
+            reverse("project_update-list", kwargs={"version": "v1"}),
+            data={
+                "filter": 'very bad python'
+            }
+        ))
+        backend = RSRGenericFilterBackend()
+        self.assertRaises(
+            APIException,
+            backend.filter_queryset,
+            request, ProjectUpdate.objects.all(), None
+        )