Repository: zulip/zulip

Issue:
Empty search query in /json/messages endpoint causes 500 internal server error.
Sending an empty search query (not possible directly from the web app UI) causes a 500 internal server error. The endpoint is json/messages and the query string is:
```
anchor=newest
num_before=50
num_after=50
narrow=[{"negated":false,"operator":"search","operand":""}]
client_gravatar=true
```
and the error stacktrace is 
```
2020-03-30 18:36:06.228 INFO [zr] 172.17.0.1      GET     500  49ms (db: 34ms/3q) /json/messages [streams,search] (iago@zulip.com via website)
2020-03-30 18:36:06.228 ERR  [django.request] Internal Server Error: /json/messages
2020-03-30 18:36:06.229 ERR  [django.server] "GET /json/messages?anchor=newest&num_before=50&num_after=50&narrow=[{%22negated%22:true,%22operator%22:%22streams%22,%22operand%22:%22public%22},{%22negated%22:false,%22operator%22:%22search%22,%22operand%22:%22%22}]&client_gravatar=true HTTP/1.1" 500 49
2020-03-30 18:36:07.830 ERR  [] Traceback (most recent call last):
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1246, in _execute_context
    cursor, statement, parameters, context
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/engine/default.py", line 588, in do_execute
    cursor.execute(statement, parameters)
  File "/srv/zulip/zerver/lib/db.py", line 31, in execute
    return wrapper_execute(self, super().execute, query, vars)
  File "/srv/zulip/zerver/lib/db.py", line 18, in wrapper_execute
    return action(sql, params)
psycopg2.errors.SystemError: pgroonga: query_extract_keywords: failed to parse expression: Syntax error: <||>


The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/django/core/handlers/base.py", line 113, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/srv/zulip/zerver/lib/rest.py", line 27, in _wrapped_view_func
    response = view_func(request, *args, **kwargs)
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/django/views/decorators/csrf.py", line 54, in wrapped_view
    return view_func(*args, **kwargs)
  File "/srv/zulip/zerver/lib/rest.py", line 166, in rest_dispatch
    return target_function(request, **kwargs)
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/django/utils/decorators.py", line 142, in _wrapped_view
    response = view_func(request, *args, **kwargs)
  File "/srv/zulip/zerver/decorator.py", line 677, in _wrapped_view_func
    return authenticate_log_and_execute_json(request, view_func, *args, **kwargs)
  File "/srv/zulip/zerver/decorator.py", line 656, in authenticate_log_and_execute_json
    return limited_view_func(request, user_profile, *args, **kwargs)
  File "/srv/zulip/zerver/decorator.py", line 796, in wrapped_func
    return func(request, *args, **kwargs)
  File "/srv/zulip/zerver/lib/request.py", line 368, in _wrapped_view_func
    return view_func(request, *args, **kwargs)
  File "/srv/zulip/zerver/views/messages.py", line 903, in get_messages_backend
    rows = list(sa_conn.execute(query).fetchall())
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 982, in execute
    return meth(self, multiparams, params)
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/sql/elements.py", line 293, in _execute_on_connection
    return connection._execute_clauseelement(self, multiparams, params)
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1101, in _execute_clauseelement
    distilled_params,
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1250, in _execute_context
    e, statement, parameters, cursor, context
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1476, in _handle_dbapi_exception
    util.raise_from_cause(sqlalchemy_exception, exc_info)
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/util/compat.py", line 398, in raise_from_cause
    reraise(type(exception), exception, tb=exc_tb, cause=cause)
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/util/compat.py", line 152, in reraise
    raise value.with_traceback(tb)
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1246, in _execute_context
    cursor, statement, parameters, context
  File "/srv/zulip-py3-venv/lib/python3.6/site-packages/sqlalchemy/engine/default.py", line 588, in do_execute
    cursor.execute(statement, parameters)
  File "/srv/zulip/zerver/lib/db.py", line 31, in execute
    return wrapper_execute(self, super().execute, query, vars)
  File "/srv/zulip/zerver/lib/db.py", line 18, in wrapper_execute
    return action(sql, params)
sqlalchemy.exc.OperationalError: (psycopg2.errors.SystemError) pgroonga: query_extract_keywords: failed to parse expression: Syntax error: <||>

[SQL: SELECT /* get_messages */ anon_1.message_id, anon_1.flags, anon_1.subject, anon_1.rendered_content, anon_1.content_matches, anon_1.topic_matches
FROM (SELECT message_id, flags, subject, rendered_content, pgroonga_match_positions_character(rendered_content, pgroonga_query_extract_keywords(escape_html(%(escape_html_1)s))) AS content_matches, pgroonga_match_positions_character(escape_html(subject), pgroonga_query_extract_keywords(escape_html(%(escape_html_1)s))) AS topic_matches
FROM zerver_usermessage JOIN zerver_message ON zerver_usermessage.message_id = zerver_message.id
WHERE user_profile_id = %(param_1)s AND recipient_id NOT IN (%(recipient_id_1)s, %(recipient_id_2)s, %(recipient_id_3)s, %(recipient_id_4)s, %(recipient_id_5)s, %(recipient_id_6)s, %(recipient_id_7)s, %(recipient_id_8)s, %(recipient_id_9)s, %(recipient_id_10)s, %(recipient_id_11)s, %(recipient_id_12)s, %(recipient_id_13)s, %(recipient_id_14)s) AND (search_pgroonga &@~ escape_html(%(escape_html_1)s)) ORDER BY message_id DESC
 LIMIT %(param_2)s) AS anon_1 ORDER BY message_id ASC]
[parameters: {'escape_html_1': '', 'param_1': 10, 'recipient_id_1': 18, 'recipient_id_2': 19, 'recipient_id_3': 20, 'recipient_id_4': 21, 'recipient_id_5': 22, 'recipient_id_6': 26, 'recipient_id_7': 27, 'recipient_id_8': 28, 'recipient_id_9': 29, 'recipient_id_10': 30, 'recipient_id_11': 31, 'recipient_id_12': 32, 'recipient_id_13': 33, 'recipient_id_14': 34, 'param_2': 50}]
(Background on this error at: http://sqlalche.me/e/e3q8)
```
on the current master (35b444d59cb00ba2ca2973a5f7fef42f844ab445).


Discussion:
This might be a bug just impacting the PGroonga search backend, based on the traceback, but it probably isn't -- we should be rejecting the `''` in the `by_search` codepath before we get to the search backend code.
Hello @zulip/server-search members, this issue was labeled with the "area: search" label, so you may want to check it out!

<!-- areaLabelAddition -->
Tagging as a priority since all known 500 errors are.
Ah, `pgroonga_query_extract_keywords()` doesn't support empty string as input for now.
I'll improve `pgroonga_query_extract_keywords()` to accept it later but please ignore empty string in Zulip.

Resolving patch:
diff --git a/zerver/views/messages.py b/zerver/views/messages.py
--- a/zerver/views/messages.py
+++ b/zerver/views/messages.py
@@ -48,7 +48,8 @@
 from zerver.lib.utils import statsd
 from zerver.lib.validator import \
     check_list, check_int, check_dict, check_string, check_bool, \
-    check_string_or_int_list, check_string_or_int, check_string_in
+    check_string_or_int_list, check_string_or_int, check_string_in, \
+    check_required_string
 from zerver.lib.zephyr import compute_mit_user_fullname
 from zerver.models import Message, UserProfile, Stream, Subscription, Client,\
     Realm, RealmDomain, Recipient, UserMessage, \
@@ -541,12 +542,15 @@ def convert_term(elem: Union[Dict[str, Any], List[str]]) -> Dict[str, Any]:
             # operators_supporting_id, or operators_supporting_ids array.
             operators_supporting_id = ['sender', 'group-pm-with', 'stream']
             operators_supporting_ids = ['pm-with']
+            operators_non_empty_operand = {'search'}
 
             operator = elem.get('operator', '')
             if operator in operators_supporting_id:
                 operand_validator = check_string_or_int
             elif operator in operators_supporting_ids:
                 operand_validator = check_string_or_int_list
+            elif operator in operators_non_empty_operand:
+                operand_validator = check_required_string
             else:
                 operand_validator = check_string
 


Test patch:
diff --git a/zerver/tests/test_narrow.py b/zerver/tests/test_narrow.py
--- a/zerver/tests/test_narrow.py
+++ b/zerver/tests/test_narrow.py
@@ -2347,6 +2347,10 @@ def test_invalid_narrow_operand_in_dict(self) -> None:
         for operand in ['is', 'near', 'has', 'id']:
             self.exercise_bad_narrow_operand_using_dict_api(operand, invalid_operands, error_msg)
 
+        # Disallow empty search terms
+        error_msg = 'elem["operand"] cannot be blank.'
+        self.exercise_bad_narrow_operand_using_dict_api('search', [''], error_msg)
+
     # The exercise_bad_narrow_operand helper method uses legacy tuple format to
     # test bad narrow, this method uses the current dict api format
     def exercise_bad_narrow_operand_using_dict_api(self, operator: str,