You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by br...@apache.org on 2026/09/17 17:56:12 UTC

(allura) 05/15: [#8624] validate search filter field names

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch db/8624b
in repository https://gitbox.apache.org/repos/asf/allura.git

commit 832bbf9221ff2f98929c04da4019a764dea95044
Author: Dave Brondsema <db...@slashdotmedia.com>
AuthorDate: Tue Sep 8 16:53:26 2026 +0000

    [#8624] validate search filter field names
    
    search_artifact() escaped filter values but built the field name into the
    fq clause unchanged, and the filter is parsed from caller-supplied JSON.
    Require field names to be plain identifiers and coerce values to str, so a
    non-string value no longer raises out of the request either.
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01CbUqCrWrninjwW3vSrxxbC
---
 Allura/allura/lib/search.py                          | 18 +++++++++++++++---
 .../forgetracker/tests/functional/test_root.py       | 20 ++++++++++++++++++++
 2 files changed, 35 insertions(+), 3 deletions(-)

diff --git a/Allura/allura/lib/search.py b/Allura/allura/lib/search.py
index 8f707a329..796a5cbcc 100644
--- a/Allura/allura/lib/search.py
+++ b/Allura/allura/lib/search.py
@@ -18,13 +18,15 @@
 
 import ast
 import re
+import sys
 from logging import getLogger
 
 
 import bson
 import markdown
 import markupsafe
-from tg import redirect, url
+from paste.deploy.converters import asbool
+from tg import redirect, url, config
 from tg import tmpl_context as c, app_globals as g
 from tg import request
 from pysolr import SolrError
@@ -35,6 +37,8 @@
 
 log = getLogger(__name__)
 
+FILTER_FIELD_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
+
 
 class SearchIndexable:
 
@@ -238,6 +242,13 @@ def search_artifact(atype, q, history=False, rows=10, short_timeout=False, filte
         filter = ast.literal_eval(filter)
     if isinstance(filter, dict):
         for name, values in (filter or {}).items():
+            # concatenated into an fq clause, so it must be a plain identifier
+            if not FILTER_FIELD_RE.match(name or ''):
+                if asbool(config['debug']) or 'pytest' in sys.modules:
+                    raise ValueError(f'Unexpected filter {name!r}: {values!r}')
+                else:
+                    log.warning('Ignoring search filter with unusable field name: %r', name)
+                    continue
             field_name = name + '_s'
             parts = []
             for v in values:
@@ -245,9 +256,10 @@ def search_artifact(atype, q, history=False, rows=10, short_timeout=False, filte
                 if v == '' or v is None:
                     part = f'(-{field_name}:[* TO *] AND *:*)'
                 else:
-                    part = f'{field_name}:{escape_solr_arg(v)}'
+                    part = f'{field_name}:{escape_solr_arg(str(v))}'
                 parts.append(part)
-            fq.append(' OR '.join(parts))
+            if parts:
+                fq.append(' OR '.join(parts))
     if not history:
         fq.append('is_history_b:False')
     return search(q, fq=fq, rows=rows, short_timeout=short_timeout, ignore_errors=False, sort=sort, start=start,
diff --git a/ForgeTracker/forgetracker/tests/functional/test_root.py b/ForgeTracker/forgetracker/tests/functional/test_root.py
index f64acd9b3..4323eec98 100644
--- a/ForgeTracker/forgetracker/tests/functional/test_root.py
+++ b/ForgeTracker/forgetracker/tests/functional/test_root.py
@@ -19,6 +19,9 @@
 import time
 import json
 from io import BytesIO
+
+import pytest
+
 import allura
 import mock
 
@@ -1381,6 +1384,23 @@ def test_search(self):
         r = self.app.get('/p/test/bugs/search/?q=test&filter=blah')
         r = self.app.get('/p/test/bugs/search/?q=test&defType=asdf')
 
+    @patch('allura.lib.search.search')
+    def test_search_filter_field_names_validated(self, search):
+        search.return_value = None
+        self.new_ticket(summary='a ticket')
+        with pytest.raises(ValueError, match=r'Unexpected filter'):
+            self.app.get('/bugs/search/', params={'q': 'x', 'filter': '{"{!func}div(1,0)": ["v"]}'})
+        assert not search.called
+
+        self.app.get('/bugs/search/', params={'q': 'x', 'filter': '{"status": ["open"]}'})
+        assert 'status_s:open' in search.call_args[1]['fq']
+
+    def test_search_filter_non_string_value(self):
+        self.new_ticket(summary='a ticket')
+        self.app.get('/bugs/search/',
+                     params={'q': 'x', 'filter': '{"status": [{"$ne": null}]}'},
+                     status=200)
+
     def test_search_canonical(self):
         self.new_ticket(summary='test first ticket')
         self.new_ticket(summary='test second ticket')