From 22cbfb5df623d1be04ac9f98605288f1ad5f6827 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 4 Apr 2026 01:18:34 +0200 Subject: [PATCH] fix(security): Email-Matching, LDAP-Validierung, Auditor-Isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mailBelongsToUser: net/mail.ParseAddressList statt strings.Contains verhindert False-Positives durch Display-Namen in Mail-Headern - LDAP mail-Attribut: net/mail.ParseAddress-Validierung vor Übernahme, Fallback auf username / username@ldap.local bei ungültiger Adresse - handleSearch: Auditor-Rolle in userEmailFilter-Check eingeschlossen, sodass Auditoren im Search-Pfad dieselbe Mail-Isolation erhalten wie User Co-Authored-By: Claude Sonnet 4.6 --- internal/api/search_handlers.go | 55 +++++++++++++++++++++++++++------ internal/auth/auth.go | 11 +++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index 06d9362..613b81b 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -3,6 +3,7 @@ package api import ( "fmt" "net/http" + "net/mail" "strconv" "strings" "time" @@ -171,11 +172,11 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs) } - // SEC: For user role, restrict results to mails the user is involved in - // (From, To, or CC). Email comes from the JWT session — no DB lookup needed. - // If email is missing for a user-role session, block all results (fail-safe). + // SEC: For user and auditor roles, restrict results to mails the user is + // involved in (From, To, or CC). Email comes from the JWT session — no DB + // lookup needed. If email is missing, block all results (fail-safe). var userEmailFilter string - if sess.Role == userstore.RoleUser { + if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor { userEmailFilter = strings.ToLower(sess.Email) if userEmailFilter == "" { writeJSON(w, http.StatusOK, map[string]interface{}{"total": 0, "hits": []interface{}{}}) @@ -418,21 +419,55 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) { w.Write(raw) } +// emailsFromHeader parses a mail header value (e.g. From, To, CC) and returns +// the bare email addresses it contains. Parsing uses net/mail.ParseAddressList +// for correctness; if parsing fails the raw value is returned as a single +// lower-cased string so that the caller can still apply a substring fallback. +func emailsFromHeader(header string) []string { + addrs, err := mail.ParseAddressList(header) + if err != nil { + // Parsing failed (e.g. malformed header) — return raw value for fallback. + return []string{strings.ToLower(header)} + } + out := make([]string, len(addrs)) + for i, a := range addrs { + out[i] = strings.ToLower(a.Address) + } + return out +} + // mailBelongsToUser checks if the user's email appears in From, To, or CC. -// Users can access mails they sent as well as mails they received. -// From may contain a display name ("Name "), so Contains is used. +// Addresses are parsed with net/mail.ParseAddressList so that display names +// ("Name ") do not cause false positives or negatives. +// Falls back to case-insensitive substring matching when parsing fails. func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool { - email := strings.ToLower(userEmail) - if strings.Contains(strings.ToLower(pm.From), email) { + target := strings.ToLower(userEmail) + + checkHeader := func(header string) bool { + parsed := emailsFromHeader(header) + for _, addr := range parsed { + // Exact match after parsing — preferred path. + if addr == target { + return true + } + // Fallback: if parsing returned the raw value (error path), use substring. + if strings.Contains(addr, target) { + return true + } + } + return false + } + + if checkHeader(pm.From) { return true } for _, to := range pm.To { - if strings.Contains(strings.ToLower(to), email) { + if checkHeader(to) { return true } } for _, cc := range pm.CC { - if strings.Contains(strings.ToLower(cc), email) { + if checkHeader(cc) { return true } } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 30fa856..0aa4870 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "net/mail" "strings" "time" @@ -117,6 +118,11 @@ func (m *Manager) Login(username, password string) (token string, user *userstor } } email := attrs["mail"] + if email != "" { + if _, err := mail.ParseAddress(email); err != nil { + email = "" // invalid mail attribute — fall back + } + } if email == "" { email = username } @@ -163,6 +169,11 @@ func (m *Manager) Login(username, password string) (token string, user *userstor } } email := attrs["mail"] + if email != "" { + if _, err := mail.ParseAddress(email); err != nil { + email = "" // invalid mail attribute — fall back + } + } if email == "" { email = username + "@ldap.local" }