Skip to content

Commit b8e19c4

Browse files
authored
Handle empty and FTS5-operator search queries without raising a 500 (#481)
* Handle empty and FTS5-operator search queries without raising a 500 Two adjacent crashes on the public book search path, both reachable via `?search=`: - A query that sanitizes down to nothing (e.g. `^$`, an emoji, `!!!`, a bare `"`) made `matches_for_highlight` return nil, so the highlight helper hit `nil.map`. Return an empty match set instead, which renders as normal un-highlighted content. - Bare FTS5 boolean operators (`OR`, `AND`, `NOT`, `NEAR`, ...) survived character sanitization and reached SQLite as an FTS5 syntax error. Rebuild the query from its balanced quoted phrases and bare words, quoting every token as a string literal so arbitrary input is matched literally instead of parsed as FTS5 syntax. This also subsumes the old unbalanced-quote handling. Ordinary term and phrase searches are unaffected. * Escape highlight terms so punctuated phrase matches don't raise FTS5 highlight() spans can include document punctuation, so a phrase match against content like "alpha(beta" hands the highlight helper a term containing regex metacharacters. Interpolating it straight into /\b...\b/ raised a RegexpError (e.g. `?search=alpha_beta` on a page containing "alpha(beta"), breaking the page render. Regexp.escape the term so it is matched literally. * Address review: path helpers, response-body assertion, fixture reuse, empty-quote phrase boundary - Use book_search_path / leaves(:welcome_section) fixture / assert_in_body in the new search tests to match the repo's testing conventions. - quote_query_tokens: consume empty quote pairs in place so a stray "" no longer shifts a following phrase's quote boundaries and splits it into separate word matches; drop empty tokens. * Scrub invalid UTF-8 before sanitizing search queries String#gsub raises ArgumentError on invalid byte sequences, so a malformed query string could raise in sanitize_query_syntax. Rails rejects malformed request encoding with a 400 before either search controller runs, so this was not reachable as a 500 over HTTP — but scrubbing keeps the shared search sink total for every caller. * Trim breadcrumby commentary from the search changes Drop the change-narrating comments on the scrub call, the highlight-term escaping, and the new tests; the code and test names carry the intent. Keep the quote_query_tokens comment, which documents non-obvious FTS5 behavior.
1 parent 96a6c71 commit b8e19c4

6 files changed

Lines changed: 121 additions & 9 deletions

File tree

app/helpers/searches_helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ def highlight_searched_content(leaf, content, query)
2323

2424
private
2525
def whole_word_matchers(terms)
26-
terms.map { |term| /\b#{term}\b/ }
26+
terms.map { |term| /\b#{Regexp.escape(term)}\b/ }
2727
end
2828
end

app/models/leaf/searchable.rb

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ def reindex_all
1515
end
1616

1717
def sanitize_query_syntax(terms)
18-
terms = terms.to_s
18+
terms = terms.to_s.scrub
1919
terms = remove_invalid_search_characters(terms)
20-
terms = remove_unbalanced_quotes(terms)
20+
terms = quote_query_tokens(terms)
2121
terms.presence
2222
end
2323

@@ -50,6 +50,8 @@ def matches_for_highlight(terms)
5050
.pick(Arel.sql("highlight(leaf_search_index, 1, '<mark>', '</mark>')"))
5151

5252
content ? unique_matching_terms(content) : []
53+
else
54+
[]
5355
end
5456
end
5557

@@ -106,12 +108,22 @@ def remove_invalid_search_characters(terms)
106108
terms.gsub(/[^\w"]/, " ")
107109
end
108110

109-
def remove_unbalanced_quotes(terms)
110-
if terms.count("\"").even?
111-
terms
112-
else
113-
terms.gsub("\"", " ")
114-
end
111+
# After stripping the characters FTS5 can't tokenize, the remaining
112+
# input may still be an FTS5 boolean operator (AND/OR/NOT/NEAR) or
113+
# carry an unbalanced double quote — either of which makes SQLite raise
114+
# a syntax error. Rebuild the query from its balanced "quoted phrases"
115+
# and bare words, wrapping every token as a quoted string literal so
116+
# arbitrary input is matched literally instead of parsed as syntax.
117+
#
118+
# Match empty quote pairs too (`[^"]*`, not `+`) so a stray `""` is
119+
# consumed in place rather than pairing its closing quote with the next
120+
# opening one — which would shift the boundaries of a following phrase
121+
# and split it into separate word matches. Empty tokens are then dropped.
122+
def quote_query_tokens(terms)
123+
terms.scan(/"[^"]*"|\w+/)
124+
.filter_map { |token| token.delete('"').presence }
125+
.map { |token| %("#{token}") }
126+
.join(" ")
115127
end
116128
end
117129
end

test/controllers/books/searches_controller_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ class Books::SearchesControllerTest < ActionDispatch::IntegrationTest
4141
assert_select "p", text: /no matches/i
4242
end
4343

44+
test "create shows no matches when the search uses FTS5 operator syntax" do
45+
[ "OR", "AND", "NOT", "great OR", "NEAR handbook", "great AND NOT" ].each do |query|
46+
post book_search_path(books(:handbook)), params: { search: query }
47+
48+
assert_response :success, "expected #{query.inspect} to render without error"
49+
end
50+
end
51+
52+
test "create still finds matches for an ordinary multi-word query" do
53+
post book_search_path(books(:handbook)), params: { search: "great handbook" }
54+
55+
assert_response :success
56+
assert_select "a.search__result"
57+
end
58+
4459
test "create does not find trashed pages" do
4560
leaves(:summary_page).trashed!
4661

test/controllers/leafables_controller_test.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,44 @@ class LeafablesControllerTest < ActionDispatch::IntegrationTest
3030
assert_select "mark", "great"
3131
end
3232

33+
test "show does not raise when the search query sanitizes to empty" do
34+
sign_out
35+
books(:handbook).update!(published: true)
36+
Leaf.reindex_all
37+
38+
[ "^$", "!!!", "🙂", "\"" ].each do |query|
39+
get leafable_slug_path(leaves(:welcome_page)), params: { search: query }
40+
41+
assert_response :success, "expected #{query.inspect} to render without error"
42+
assert_in_body "a great handbook."
43+
end
44+
end
45+
46+
test "show does not raise when the search query uses FTS5 operator syntax" do
47+
sign_out
48+
books(:handbook).update!(published: true)
49+
Leaf.reindex_all
50+
51+
[ "OR", "AND", "NOT", "great OR", "NEAR handbook", "great AND NOT" ].each do |query|
52+
get leafable_slug_path(leaves(:welcome_page)), params: { search: query }
53+
54+
assert_response :success, "expected #{query.inspect} to render without error"
55+
end
56+
end
57+
58+
test "show does not raise when a phrase match spans regex metacharacters" do
59+
sign_out
60+
books(:handbook).update!(published: true)
61+
62+
sections(:welcome).update!(body: "alpha(beta gamma in the body")
63+
leaves(:welcome_section).reindex
64+
65+
get leafable_slug_path(leaves(:welcome_section)), params: { search: "alpha_beta" }
66+
67+
assert_response :success
68+
assert_select "mark", text: /alpha\(beta/
69+
end
70+
3371
test "show does not allow public access to an unpublished book" do
3472
sign_out
3573

test/helpers/searches_helper_test.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
require "test_helper"
22

33
class SearchesHelperTest < ActionView::TestCase
4+
include PagesHelper
5+
46
test "sanitize_search_result preserves mark tags" do
57
assert_equal "<mark>findme</mark> text", sanitize_search_result("<mark>findme</mark> text")
68
end
@@ -16,4 +18,14 @@ class SearchesHelperTest < ActionView::TestCase
1618
test "sanitize_search_result strips attributes from mark tags" do
1719
assert_equal "<mark>findme</mark> text", sanitize_search_result('<mark class="hidden">findme</mark> text')
1820
end
21+
22+
test "highlight_searched_content handles matched terms containing regex metacharacters" do
23+
leaf = Struct.new(:terms) do
24+
def matches_for_highlight(_query) = terms
25+
end.new([ "alpha(beta" ])
26+
27+
result = highlight_searched_content(leaf, "alpha(beta in the body", "alpha beta")
28+
29+
assert_includes result, "<mark>alpha(beta</mark>"
30+
end
1931
end

test/models/leaf/searchable_test.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,41 @@ class Leaf::SearchableTest < ActiveSupport::TestCase
3838
assert_empty markup
3939
end
4040

41+
test "matches_for_highlight is empty when the query sanitizes to nothing" do
42+
assert_empty leaves(:welcome_page).matches_for_highlight("^$")
43+
assert_empty leaves(:welcome_page).matches_for_highlight("🙂")
44+
assert_empty leaves(:welcome_page).matches_for_highlight("\"")
45+
end
46+
47+
test "search treats FTS5 operators as literal terms rather than syntax" do
48+
assert_empty Leaf.search("OR")
49+
assert_empty Leaf.search("great AND NOT")
50+
assert_empty Leaf.search("great OR handbook")
51+
52+
assert_includes Leaf.search("great handbook"), leaves(:welcome_page)
53+
assert_includes Leaf.search("\"great handbook\""), leaves(:welcome_page)
54+
end
55+
56+
test "a stray empty quote pair does not split a following phrase" do
57+
sections(:welcome).update!(body: "great old handbook")
58+
leaves(:welcome_section).reindex
59+
60+
results = Leaf.search("\"\" \"great handbook\"")
61+
62+
assert_includes results, leaves(:welcome_page)
63+
assert_not_includes results, leaves(:welcome_section)
64+
end
65+
66+
test "search does not raise on invalid UTF-8 byte sequences" do
67+
malformed = "caf\xFF".dup.force_encoding("UTF-8")
68+
assert_not malformed.valid_encoding?
69+
70+
assert_nothing_raised do
71+
assert_empty Leaf.search(malformed)
72+
assert_empty leaves(:welcome_page).matches_for_highlight(malformed)
73+
end
74+
end
75+
4176
test "indexing sanitizes section body" do
4277
section = Section.new(body: 'findme Tom & Jerry <img src=x onerror="alert(1)">')
4378
books(:handbook).press(section, title: "Safe Title")

0 commit comments

Comments
 (0)