mesh-mail: mail_search's address filter never treats % or _ as a wildcard, so a literal domain filter does not leak every mail in Postgres (issue 1228, review finding 4)

This commit is contained in:
jochen
2026-10-19 14:52:36 +02:00
parent b60ee706d4
commit 1c3158e098
2 changed files with 33 additions and 5 deletions
+9 -5
View File
@@ -397,15 +397,19 @@ func (s *PgStore) Get(ctx context.Context, account, folder string, uid uint32) (
// addressFilterSQL is 1228/AC2, 1228/AC4, 1228/AC6(cc): an EXISTS over the jsonb array column (recipients or
// copied), matched against one already-lowercased parameter either as a whole address or, with no "@", as a
// domain (every address under it). column names a fixed set the Go code chooses, never caller input.
// domain (every address under it): right(address, length(domain)+1) = '@'||domain, an exact substring compare,
// not LIKE, so a filter holding "%" or "_" is never a wildcard (issue 1228, review round 1 finding 4: a filter
// from:["%"] matched every mail in Postgres and none in memStore, since memStore's strings.HasSuffix never
// treats "%" specially). column names a fixed set the Go code chooses, never caller input.
func addressFilterSQL(column, param string) string {
return fmt.Sprintf("exists (select 1 from jsonb_array_elements(%s) el where lower(el->>'address') = %s or "+
"lower(el->>'address') like '%%@' || %s)", column, param, param)
"right(lower(el->>'address'), length(%s) + 1) = '@' || %s)", column, param, param, param)
}
// addressFilterSQLAny is 1228/AC3; 1230/AC11 ("several domains"): an EXISTS over column (sender), matched
// against any of filters, OR'd (each already-lowercased, each either a whole address or, with no "@", a domain).
// The whole list reaches SQL as one parameter, a text[]; never concatenated.
// against any of filters, OR'd (each already-lowercased, each either a whole address or, with no "@", a domain),
// the same exact substring compare as addressFilterSQL, never LIKE. The whole list reaches SQL as one
// parameter, a text[]; never concatenated.
func addressFilterSQLAny(column string, filters []string, param func(any) string) string {
lowered := make([]string, len(filters))
for i, f := range filters {
@@ -413,7 +417,7 @@ func addressFilterSQLAny(column string, filters []string, param func(any) string
}
p := param(lowered)
return fmt.Sprintf("exists (select 1 from jsonb_array_elements(%s) el, unnest(%s::text[]) f where "+
"lower(el->>'address') = f or lower(el->>'address') like '%%@' || f)", column, p)
"lower(el->>'address') = f or right(lower(el->>'address'), length(f) + 1) = '@' || f)", column, p)
}
// wordFilterSQL is 1229/AC5, AC6(words), AC9, AC12, AC13, AC15: one condition per word of the list, each a
@@ -208,6 +208,30 @@ func TestSearchFiltersByCcNotTo(t *testing.T) {
})
}
// 1228 (review round 1, finding 4): a domain filter of "%" or "_" is never a LIKE wildcard; it matches nobody,
// the same in both stores (a Postgres-only bug matched every mail in Postgres, since '%@' || '%' is the
// wildcard '%@%', while memStore's strings.HasSuffix never treats '%' specially).
func TestSearchFiltersByDomainNeverTreatsPercentAsAWildcard(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
got, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, From: []string{"%"}, Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("from:[%%] matched %d mail(s): %v", len(got), got)
}
toUnderscore, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, To: "_", Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(toUnderscore) != 0 {
t.Fatalf("to:_ matched %d mail(s): %v", len(toUnderscore), toUnderscore)
}
})
}
// 1229/AC6 (words part): cc <my address> and the word "renovatie" in subject or body, combined with the cc filter
// from 1228: the mail with it only in to is still excluded.
func TestSearchFiltersByCcAndSubjectOrBodyWord(t *testing.T) {