From 1c3158e098f812e556497f119e4628955a8f9efc Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 14:52:36 +0200 Subject: [PATCH] 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) --- modules/mesh-mail/cmd/mesh-mail/pgstore.go | 14 +++++++---- .../mesh-mail/cmd/mesh-mail/search_test.go | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/modules/mesh-mail/cmd/mesh-mail/pgstore.go b/modules/mesh-mail/cmd/mesh-mail/pgstore.go index a02cf46c..bd06a046 100644 --- a/modules/mesh-mail/cmd/mesh-mail/pgstore.go +++ b/modules/mesh-mail/cmd/mesh-mail/pgstore.go @@ -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 diff --git a/modules/mesh-mail/cmd/mesh-mail/search_test.go b/modules/mesh-mail/cmd/mesh-mail/search_test.go index 9c69879e..8f7c21bf 100644 --- a/modules/mesh-mail/cmd/mesh-mail/search_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/search_test.go @@ -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 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) {