fix(security): Email-Matching, LDAP-Validierung, Auditor-Isolation

- 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 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-04 01:18:34 +02:00
parent 36d8db1574
commit 22cbfb5df6
2 changed files with 56 additions and 10 deletions
+45 -10
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/mail"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -171,11 +172,11 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs) labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs)
} }
// SEC: For user role, restrict results to mails the user is involved in // SEC: For user and auditor roles, restrict results to mails the user is
// (From, To, or CC). Email comes from the JWT session — no DB lookup needed. // involved in (From, To, or CC). Email comes from the JWT session — no DB
// If email is missing for a user-role session, block all results (fail-safe). // lookup needed. If email is missing, block all results (fail-safe).
var userEmailFilter string var userEmailFilter string
if sess.Role == userstore.RoleUser { if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
userEmailFilter = strings.ToLower(sess.Email) userEmailFilter = strings.ToLower(sess.Email)
if userEmailFilter == "" { if userEmailFilter == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"total": 0, "hits": []interface{}{}}) 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) 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. // 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. // Addresses are parsed with net/mail.ParseAddressList so that display names
// From may contain a display name ("Name <addr>"), so Contains is used. // ("Name <addr>") 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 { func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool {
email := strings.ToLower(userEmail) target := strings.ToLower(userEmail)
if strings.Contains(strings.ToLower(pm.From), email) {
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 return true
} }
for _, to := range pm.To { for _, to := range pm.To {
if strings.Contains(strings.ToLower(to), email) { if checkHeader(to) {
return true return true
} }
} }
for _, cc := range pm.CC { for _, cc := range pm.CC {
if strings.Contains(strings.ToLower(cc), email) { if checkHeader(cc) {
return true return true
} }
} }
+11
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/mail"
"strings" "strings"
"time" "time"
@@ -117,6 +118,11 @@ func (m *Manager) Login(username, password string) (token string, user *userstor
} }
} }
email := attrs["mail"] email := attrs["mail"]
if email != "" {
if _, err := mail.ParseAddress(email); err != nil {
email = "" // invalid mail attribute — fall back
}
}
if email == "" { if email == "" {
email = username email = username
} }
@@ -163,6 +169,11 @@ func (m *Manager) Login(username, password string) (token string, user *userstor
} }
} }
email := attrs["mail"] email := attrs["mail"]
if email != "" {
if _, err := mail.ParseAddress(email); err != nil {
email = "" // invalid mail attribute — fall back
}
}
if email == "" { if email == "" {
email = username + "@ldap.local" email = username + "@ldap.local"
} }