bug: escape LIKE wildcard chars in search queries (#145)

User search queries were wrapped in `%...%` LIKE patterns without escaping SQL LIKE wildcards, causing `%` searches to return every message in the database.

This adds an `escapeLIKE` helper that escapes `\`, `%`, and `_` before wrapping them, adding `ESCAPE '\'` clauses to all LIKE predicates to ensure user input is treated as literal text.

Closes #56
This commit is contained in:
Martín Alcalá Rubí 2026-04-15 05:25:19 +08:00 committed by GitHub
parent 7a2b323098
commit ff2d74de9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 57 additions and 2 deletions

View File

@ -30,13 +30,23 @@ func (d *DB) SearchMessages(p SearchMessagesParams) ([]Message, error) {
return d.searchLIKE(p)
}
// escapeLIKE escapes SQL LIKE wildcard characters (%, _) and the escape
// character itself so that user input is treated as a literal string (#56).
func escapeLIKE(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func (d *DB) searchLIKE(p SearchMessagesParams) ([]Message, error) {
query := `
SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE (LOWER(m.text) LIKE LOWER(?) OR LOWER(m.display_text) LIKE LOWER(?) OR LOWER(m.media_caption) LIKE LOWER(?) OR LOWER(m.filename) LIKE LOWER(?) OR LOWER(COALESCE(m.chat_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(m.sender_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(c.name,'')) LIKE LOWER(?))`
needle := "%" + p.Query + "%"
WHERE (LOWER(m.text) LIKE LOWER(?) ESCAPE '\' OR LOWER(m.display_text) LIKE LOWER(?) ESCAPE '\' OR LOWER(m.media_caption) LIKE LOWER(?) ESCAPE '\' OR LOWER(m.filename) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(m.chat_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(m.sender_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.name,'')) LIKE LOWER(?) ESCAPE '\')`
// Escape wildcards before wrapping in % so user input is literal (#56).
needle := "%" + escapeLIKE(p.Query) + "%"
args := []interface{}{needle, needle, needle, needle, needle, needle, needle}
query, args = applyMessageFilters(query, args, p)
query += " ORDER BY m.ts DESC LIMIT ?"

View File

@ -41,3 +41,48 @@ func TestSearchMessagesUsesLIKEWhenFTSDisabled(t *testing.T) {
t.Fatalf("expected empty snippet for LIKE search, got %q", ms[0].Snippet)
}
}
// TestSearchLIKEWildcardEscape verifies that LIKE wildcard characters in user
// queries are treated as literals, not SQL pattern chars (#56).
func TestSearchLIKEWildcardEscape(t *testing.T) {
db := openTestDB(t)
chat := "555@s.whatsapp.net"
if err := db.UpsertChat(chat, "dm", "Bob", time.Now()); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
msgs := []struct{ id, text string }{
{"m1", "hello world"},
{"m2", "100% sure"},
{"m3", "some_thing here"},
{"m4", "another message"},
}
for _, m := range msgs {
if err := db.UpsertMessage(UpsertMessageParams{
ChatJID: chat, MsgID: m.id, Timestamp: time.Now(), Text: m.text,
}); err != nil {
t.Fatalf("UpsertMessage %s: %v", m.id, err)
}
}
t.Run("percent returns only exact match", func(t *testing.T) {
// Without escaping, '%' would match everything.
ms, err := db.SearchMessages(SearchMessagesParams{Query: "100%", Limit: 50})
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if len(ms) != 1 || ms[0].MsgID != "m2" {
t.Fatalf("expected only m2, got %d results: %v", len(ms), ms)
}
})
t.Run("underscore returns only exact match", func(t *testing.T) {
// Without escaping, '_' would match any single character.
ms, err := db.SearchMessages(SearchMessagesParams{Query: "some_thing", Limit: 50})
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if len(ms) != 1 || ms[0].MsgID != "m3" {
t.Fatalf("expected only m3, got %d results: %v", len(ms), ms)
}
})
}