fix(search): cap query length at the shared search-param helper (F3)

whoosh-compat's fieldname tagger is O(n^2) in plain word characters,
reachable only through SearchMode.QUERY's whoosh grammar. Measured
against the real field registry: ~1s at 10k chars, ~3.7s at 20k, ~14.4s
at 40k. The POST selection-filter path (bulk edit, bulk download) has
no server-imposed length bound the way the GET path incidentally does
via header limits, making an unbounded query a single-request CPU
exhaustion vector.

Cap both entry points at their shared choke point,
_get_tantivy_query_and_mode, with a new QueryTooLongError that reuses
the existing SearchQueryError -> 400 routing both callers already
have. 4096 chars bounds the worst case to roughly 0.16s by quadratic
extrapolation, far beyond any plausible hand-typed query. TEXT and
TITLE modes route through simple_search_tokens instead and measure
linear even at 20k chars, so the same cap is hygiene for them rather
than a fix. Hardcoded rather than a PAPERLESS_* setting: this is a
security boundary, and a raisable ceiling could reintroduce the exact
hazard it exists to close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
stumpylog
2026-08-20 11:30:30 -07:00
co-authored by Claude Opus 5
parent 7cb6b32a8f
commit 885bc2fdf3
4 changed files with 200 additions and 6 deletions
+17
View File
@@ -34,6 +34,23 @@ class InvalidNumberQuery(SearchQueryError):
super().__init__(f"Invalid numeric value {value!r} for field {field!r}.")
class QueryTooLongError(SearchQueryError):
"""Raised when a query string exceeds the maximum allowed length.
whoosh-compat's fieldname tagger is O(n^2) in plain word characters, so an
unbounded query is a CPU-exhaustion vector against a single request
handler. This is a hard boundary, not a validation nicety.
"""
def __init__(self, length: int, limit: int) -> None:
self.length = length
self.limit = limit
super().__init__(
f"The search query is too long ({length} characters). "
f"The maximum allowed length is {limit} characters.",
)
class MultipleSearchQueryErrors(SearchQueryError):
"""Aggregates every user-fixable error from one parse, not just the first."""