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')