Repository: plone/plone.restapi (language: python)

Issue:
`@querystring-search` endpoint doesn't validate input sufficiently
There are various ways to give `@querystring-search` invalid input that causes a 500 error. These should return a 400 Bad Request response instead, to avoid cluttering error logs.

Examples:
1. https://demo.plone.org/++api++/@querystring-search?query={ (Raises DeserializationError)
2. https://demo.plone.org/++api++/@querystring-search?query={"b_size":"1*100","query":[{"i":"portal_type","o":"plone.app.querystring.operation.selection.any","v":["News+Item"]}]} (raises ValueError)
3. https://demo.plone.org/++api++/@querystring-search?query={"query":[{"i":"portal_type","o":"bogus","v":["News+Item"]}]} (raises KeyError)
ComponentLookupError in querystring-search when query is an empty list
When we call the `querystring-search` endpoint passing an empty list in the `query` parameter, we get the error below:

```bash
2023-05-29 15:16:45 ERROR [Zope.SiteErrorLog:35][waitress-1] ComponentLookupError: http://localhost:8080/Plone/@querystring-search
Traceback (innermost last):
  Module ZPublisher.WSGIPublisher, line 181, in transaction_pubevents
  Module ZPublisher.WSGIPublisher, line 390, in publish_module
  Module ZPublisher.WSGIPublisher, line 285, in publish
  Module ZPublisher.mapply, line 85, in mapply
  Module ZPublisher.WSGIPublisher, line 68, in call_object
  Module plone.rest.service, line 22, in __call__
  Module plone.restapi.services, line 19, in render
  Module plone.restapi.services.querystringsearch.get, line 75, in reply
  Module plone.restapi.services.querystringsearch.get, line 64, in __call__
  Module zope.component._api, line 104, in getMultiAdapter
zope.interface.interfaces.ComponentLookupError: (([], <WSGIRequest, URL=http://localhost:8080/Plone/@querystring-search>), <InterfaceClass plone.restapi.interfaces.ISerializeToJson>, '')
```

To reproduce, run:

```bash
curl -i -X POST http://localhost:8080/Plone/@querystring-search -H "Accept: application/json" -H "Content-Type: application/json" --data-raw '{"query": []}'
```


Discussion / hints:

Fix patch:
diff --git a/news/1653.bugfix b/news/1653.bugfix
new file mode 100644
index 0000000000..1ffb310b87
--- /dev/null
+++ b/news/1653.bugfix
@@ -0,0 +1,1 @@
+Validate input to the `@querystring-search` service. Input which can't be processed now results in a 400 response instead of 500. @davisagli
diff --git a/src/plone/restapi/services/querystringsearch/get.py b/src/plone/restapi/services/querystringsearch/get.py
index e59c9b5c84..acd9f3647b 100644
--- a/src/plone/restapi/services/querystringsearch/get.py
+++ b/src/plone/restapi/services/querystringsearch/get.py
@@ -2,9 +2,11 @@
 from pkg_resources import parse_version
 from plone.restapi.bbb import IPloneSiteRoot
 from plone.restapi.deserializer import json_body
+from plone.restapi.exceptions import DeserializationError
 from plone.restapi.interfaces import ISerializeToJson
 from plone.restapi.services import Service
 from urllib import parse
+from zExceptions import BadRequest
 from zope.component import getMultiAdapter
 
 
@@ -23,17 +25,30 @@ def __init__(self, context, request):
         self.request = request
 
     def __call__(self):
-        data = json_body(self.request)
+        try:
+            data = json_body(self.request)
+        except DeserializationError as err:
+            raise BadRequest(str(err))
+
         query = data.get("query", None)
-        b_start = int(data.get("b_start", 0))
-        b_size = int(data.get("b_size", 25))
+        try:
+            b_start = int(data.get("b_start", 0))
+        except ValueError:
+            raise BadRequest("Invalid b_start")
+        try:
+            b_size = int(data.get("b_size", 25))
+        except ValueError:
+            raise BadRequest("Invalid b_size")
         sort_on = data.get("sort_on", None)
         sort_order = data.get("sort_order", None)
-        limit = int(data.get("limit", 1000))
-        fullobjects = data.get("fullobjects", False)
+        try:
+            limit = int(data.get("limit", 1000))
+        except ValueError:
+            raise BadRequest("Invalid limit")
+        fullobjects = bool(data.get("fullobjects", False))
 
-        if query is None:
-            raise Exception("No query supplied")
+        if not query:
+            raise BadRequest("No query supplied")
 
         if sort_order:
             sort_order = "descending" if sort_order == "descending" else "ascending"
@@ -59,7 +74,13 @@ def __call__(self):
                 dict(custom_query={"UID": {"not": self.context.UID()}})
             )
 
-        results = querybuilder(**querybuilder_parameters)
+        try:
+            results = querybuilder(**querybuilder_parameters)
+        except KeyError:
+            # This can happen if the query has an invalid operation,
+            # but plone.app.querystring doesn't raise an exception
+            # with specific info.
+            raise BadRequest("Invalid query.")
 
         results = getMultiAdapter((results, self.request), ISerializeToJson)(
             fullobjects=fullobjects


Test patch:
diff --git a/src/plone/restapi/tests/test_services_querystringsearch.py b/src/plone/restapi/tests/test_services_querystringsearch.py
index 97199aeee5..442935a7ae 100644
--- a/src/plone/restapi/tests/test_services_querystringsearch.py
+++ b/src/plone/restapi/tests/test_services_querystringsearch.py
@@ -319,3 +319,45 @@ def test_querystringsearch_sort(self):
             response.json()["items"][-1]["@id"],
             f"{self.portal.absolute_url()}/testdocument9",
         )
+
+    def test_querystringsearch__bad_json(self):
+        response = self.api_session.get("/@querystring-search?query={")
+        self.assertEqual(response.status_code, 400)
+
+    def test_querystringsearch__empty_query(self):
+        response = self.api_session.post(
+            "/@querystring-search",
+            json={"query": []},
+        )
+        self.assertEqual(response.status_code, 400)
+
+    def test_querystringsearch__bad_b_size(self):
+        response = self.api_session.post(
+            "/@querystring-search",
+            json={
+                "query": [
+                    {
+                        "i": "portal_type",
+                        "o": "plone.app.querystring.operation.selection.is",
+                        "v": ["Document"],
+                    }
+                ],
+                "b_size": "x",
+            },
+        )
+        self.assertEqual(response.status_code, 400)
+
+    def test_querystringsearch__bad_operation(self):
+        response = self.api_session.post(
+            "/@querystring-search",
+            json={
+                "query": [
+                    {
+                        "i": "portal_type",
+                        "o": "BOGUS",
+                        "v": ["Document"],
+                    }
+                ],
+            },
+        )
+        self.assertEqual(response.status_code, 400)