fix(security): Kritische Sicherheitslücken beheben (SEC-01/02/03/05/08/17/22/26/28)

- SEC-01: Privilege Escalation verhindert — Rollenhierarchie in Create/Update/DeleteUser
- SEC-02: Tenant-Isolation in Update/DeleteUser — domain_admin nur eigene Nutzer
- SEC-03: IMAP/POP3 Owner-Check via auth.HasRole statt direktem String-Vergleich
- SEC-05: Export PDF/ZIP prüft Tenant-Zugehörigkeit vor Dateiausgabe
- SEC-08: HKDF-SHA256 trennt JWT-Secret von AES-Key (archivmail-jwt-v1 / archivmail-aes-v1)
- SEC-17: handleSecurityFix erfordert requireRole(superadmin)
- SEC-22: Mail-ID Regex [0-9a-f]{64} in allen Handlern (Path-Traversal-Schutz)
- SEC-26: SMTP Fail-Closed — leere AllowedIPs blockiert alles statt zu erlauben
- SEC-28: handleGetRaw — Parse-Fehler bricht ab statt Fallthrough zu Dateizugriff

BREAKING: IMAP/POP3/LDAP-Passwörter müssen nach Deploy einmalig neu eingegeben
werden (neuer AES-Key). JWT-Sessions laufen ab (einmaliges Re-Login nötig).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-18 00:05:47 +01:00
parent 143db65755
commit 46d7bfe608
6 changed files with 200 additions and 31 deletions
+43 -1
View File
@@ -327,6 +327,11 @@ func buildMailPDF(id string, pm *mailparser.ParsedMail, rawSize int) []byte {
func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// SEC-22: Validate mail ID format to prevent path traversal.
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
raw, err := s.store.Load(id)
if err != nil {
@@ -340,8 +345,18 @@ func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
return
}
// User role: only own mailbox
sess := sessionFromCtx(r.Context())
// SEC-05: Tenant isolation for PDF export.
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// User role: only own mailbox
if sess.Role == userstore.RoleUser {
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
@@ -394,8 +409,35 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
return
}
// SEC-22: Validate all mail IDs before processing.
for _, id := range req.IDs {
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
}
sess := sessionFromCtx(r.Context())
// SEC-05: Tenant isolation — verify all requested IDs belong to caller's tenant.
if sess.TenantID != nil {
allowedIDs, err := s.store.GetAllIDsByTenant(r.Context(), sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "tenant check failed")
return
}
allowed := make(map[string]struct{}, len(allowedIDs))
for _, aid := range allowedIDs {
allowed[aid] = struct{}{}
}
for _, id := range req.IDs {
if _, ok := allowed[id]; !ok {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
}
// For RoleUser, look up user email once for access checks
var userEmail string
if sess.Role == userstore.RoleUser {