diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index b4ccc1e..8a2f49c 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -3,9 +3,11 @@ package main import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "flag" "fmt" + "io" "log/slog" "net/http" "os" @@ -14,6 +16,8 @@ import ( "syscall" "time" + "golang.org/x/crypto/hkdf" + "github.com/archivmail/config" "github.com/archivmail/internal/api" "github.com/archivmail/internal/audit" @@ -67,6 +71,26 @@ func main() { os.Exit(1) } + // SEC-08: Derive separate keys from the master secret to prevent key reuse. + // jwtSecret is used for JWT token signing only. + // aesKey is used for AES-256-GCM encryption of stored passwords (IMAP, POP3, LDAP). + // HKDF is deterministic: same cfg.API.Secret always produces the same derived keys. + // NOTE: After this change, existing stored IMAP/POP3/LDAP passwords must be + // re-entered once, as they were encrypted with the old undivided key. + masterKey := []byte(cfg.API.Secret) + jwtKeyRaw := make([]byte, 32) + if _, err := io.ReadFull(hkdf.New(sha256.New, masterKey, []byte("archivmail-jwt-v1"), nil), jwtKeyRaw); err != nil { + logger.Error("key derivation failed", "err", err) + os.Exit(1) + } + aesKeyRaw := make([]byte, 32) + if _, err := io.ReadFull(hkdf.New(sha256.New, masterKey, []byte("archivmail-aes-v1"), nil), aesKeyRaw); err != nil { + logger.Error("key derivation failed", "err", err) + os.Exit(1) + } + jwtSecret := hex.EncodeToString(jwtKeyRaw) + aesKey := hex.EncodeToString(aesKeyRaw) + // Storage with encryption + DB metadata storeCfg := storage.Config{ Dir: cfg.Storage.StorePath, @@ -127,7 +151,7 @@ func main() { } // LDAP config store - ldapSt, err := ldapcfg.New(cfg.Database.DSN(), cfg.API.Secret) + ldapSt, err := ldapcfg.New(cfg.Database.DSN(), aesKey) if err != nil { logger.Error("ldap config store init failed", "err", err) os.Exit(1) @@ -135,12 +159,12 @@ func main() { defer ldapSt.Close() // Auth manager (with LDAP fallback) - authMgr := auth.New(users, ldapSt, cfg.API.Secret) + authMgr := auth.New(users, ldapSt, jwtSecret) // API server apiCfg := config.APIConfig{ Bind: cfg.API.Bind, - Secret: cfg.API.Secret, + Secret: jwtSecret, } srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger) @@ -200,7 +224,7 @@ func main() { srv.SetSMTPDaemon(smtpDaemon) // IMAP store + importer + scheduler (wired to use async worker) - imapSt, err := imapstore.New(cfg.Database.DSN(), cfg.API.Secret) + imapSt, err := imapstore.New(cfg.Database.DSN(), aesKey) if err != nil { logger.Error("imap store init failed", "err", err) os.Exit(1) @@ -213,7 +237,7 @@ func main() { srv.SetImap(imapSt, imapImp, imapSched) // POP3 store + importer - pop3St, err := pop3store.New(cfg.Database.DSN(), cfg.API.Secret) + pop3St, err := pop3store.New(cfg.Database.DSN(), aesKey) if err != nil { logger.Error("pop3 store init failed", "err", err) os.Exit(1) diff --git a/config/config.go b/config/config.go index c2c022a..c03b064 100644 --- a/config/config.go +++ b/config/config.go @@ -56,6 +56,9 @@ func (d DatabaseConfig) DSN() string { } // SMTPConfig holds settings for the embedded SMTP server. +// SEC-26: AllowedIPs uses fail-closed logic. An empty list means NO IP is +// allowed to connect. To accept from any IP, explicitly set: +// allowed_ips: ["0.0.0.0/0", "::/0"] type SMTPConfig struct { Enabled bool `yaml:"enabled"` Bind string `yaml:"bind"` diff --git a/features/INDEX.md b/features/INDEX.md index 8490a0b..79bbb02 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -35,7 +35,8 @@ | PROJ-20 | Nutzer-Löschung & E-Mail-Verbleib (GoBD-konform) | Deployed | [PROJ-20](PROJ-20-nutzer-loeschung.md) | 2026-03-17 | | PROJ-21 | Multi-Mandanten-Fähigkeit (Multi-Tenancy) | In Progress | [PROJ-21](PROJ-21-multi-tenancy.md) | 2026-03-17 | | PROJ-22 | LDAP / AD – Web-GUI Konfiguration & Test | Deployed | [PROJ-22](PROJ-22-ldap-webgui.md) | 2026-03-17 | +| PROJ-23 | Pro-Mandant LDAP / Active Directory (Multi-Tenant Phase B) | Planned | [PROJ-23](PROJ-23-tenant-ldap-pro-mandant.md) | 2026-03-17 | -## Next Available ID: PROJ-23 +## Next Available ID: PROJ-24 diff --git a/internal/api/export.go b/internal/api/export.go index 74213e0..cf52cf9 100644 --- a/internal/api/export.go +++ b/internal/api/export.go @@ -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 { diff --git a/internal/api/server.go b/internal/api/server.go index 77d3936..20bbded 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -17,6 +17,8 @@ import ( "syscall" "time" + "regexp" + "github.com/archivmail/config" "github.com/archivmail/internal/audit" "github.com/archivmail/internal/auth" @@ -31,6 +33,27 @@ import ( "github.com/archivmail/pkg/mailparser" ) +// SEC-22: Compiled regex for mail ID validation to prevent path traversal. +var mailIDRegex = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// isValidMailID validates that a mail ID matches the expected hex format. +func isValidMailID(id string) bool { + return mailIDRegex.MatchString(id) +} + +// roleLevel returns the privilege level for a role string. +// Hierarchy: superadmin=5 > admin=4 > domain_admin=3 > auditor=2 > user=1 +func roleLevel(role string) int { + levels := map[string]int{ + userstore.RoleUser: 1, + userstore.RoleAuditor: 2, + userstore.RoleDomainAdmin: 3, + userstore.RoleAdmin: 4, + userstore.RoleSuperAdmin: 5, + } + return levels[role] +} + type contextKey string const ( @@ -132,7 +155,8 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/admin/system/stats", s.authAdmin(s.handleSystemStats)) s.mux.HandleFunc("GET /api/admin/security/audit", s.authAdmin(s.handleSecurityAudit)) - s.mux.HandleFunc("POST /api/admin/security/fix", s.authAdmin(s.handleSecurityFix)) + // SEC-17: Security fix actions require superadmin, not just domain_admin. + s.mux.HandleFunc("POST /api/admin/security/fix", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSecurityFix))) // Export routes s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF))) @@ -350,18 +374,33 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { return } + // SEC-01: Privilege escalation check — caller must not assign a role + // at or above their own level. + sess := sessionFromCtx(r.Context()) + if roleLevel(req.Role) >= roleLevel(sess.Role) { + writeError(w, http.StatusForbidden, "insufficient privileges to assign this role") + return + } + + // SEC-02: Tenant isolation — non-superadmin users can only create users + // within their own tenant. + var tenantID *int64 + if sess.TenantID != nil { + tenantID = sess.TenantID + } + user, err := s.users.Create(userstore.CreateUserRequest{ Username: req.Username, Email: req.Email, Password: req.Password, Role: req.Role, + TenantID: tenantID, }) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } - sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, @@ -397,6 +436,33 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } + sess := sessionFromCtx(r.Context()) + + // SEC-02: Tenant isolation — load target user and verify same tenant. + target, err := s.users.GetByID(id) + if err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + if sess.TenantID != nil { + if target.TenantID == nil || *target.TenantID != *sess.TenantID { + writeError(w, http.StatusForbidden, "access denied") + return + } + } + + // SEC-01: Privilege escalation check — caller must not assign a role + // at or above their own level, and must not modify users at or above + // their own level. + if roleLevel(target.Role) >= roleLevel(sess.Role) { + writeError(w, http.StatusForbidden, "insufficient privileges to modify this user") + return + } + if req.Role != nil && roleLevel(*req.Role) >= roleLevel(sess.Role) { + writeError(w, http.StatusForbidden, "insufficient privileges to assign this role") + return + } + updated, err := s.users.Update(id, userstore.UpdateUserRequest{ Email: req.Email, Role: req.Role, @@ -408,7 +474,6 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } - sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, @@ -440,6 +505,21 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { return } + // SEC-02: Tenant isolation — domain_admin can only delete users in their own tenant. + sess := sessionFromCtx(r.Context()) + if sess.TenantID != nil { + if target.TenantID == nil || *target.TenantID != *sess.TenantID { + writeError(w, http.StatusForbidden, "access denied") + return + } + } + + // SEC-01: Cannot delete users at or above own privilege level. + if roleLevel(target.Role) >= roleLevel(sess.Role) { + writeError(w, http.StatusForbidden, "insufficient privileges to delete this user") + return + } + if err := s.users.DeleteSafe(id); err != nil { if err.Error() == "userstore: cannot delete last admin" { writeError(w, http.StatusConflict, "cannot delete the last active admin") @@ -459,7 +539,6 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { } } - sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, @@ -808,6 +887,11 @@ func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc { func (s *Server) handleGetMail(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 { @@ -892,6 +976,11 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetAttachment(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 + } indexStr := r.PathValue("index") idx, err := strconv.Atoi(indexStr) if err != nil { @@ -950,6 +1039,11 @@ func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetRaw(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 { @@ -968,15 +1062,17 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) { } } - // Access check for user role + // SEC-28: Access check for user role — parse failure must NOT grant access. if sess.Role == userstore.RoleUser { pm, err := mailparser.Parse(raw) - if err == nil { - u, err := s.users.GetByUsername(sess.Username) - if err != nil || !mailBelongsToUser(pm, u.Email) { - writeError(w, http.StatusForbidden, "access denied") - return - } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to parse mail") + return + } + u, err := s.users.GetByUsername(sess.Username) + if err != nil || !mailBelongsToUser(pm, u.Email) { + writeError(w, http.StatusForbidden, "access denied") + return } } @@ -1182,7 +1278,8 @@ func (s *Server) handleListImap(w http.ResponseWriter, r *http.Request) { return } sess := sessionFromCtx(r.Context()) - isAdmin := sess.Role == userstore.RoleAdmin + // SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin). + isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin) accounts, err := s.imapStore.List(r.Context(), sess.Username, isAdmin) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list IMAP accounts") @@ -1265,7 +1362,7 @@ func (s *Server) handleDeleteImap(w http.ResponseWriter, r *http.Request) { } sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } @@ -1353,7 +1450,7 @@ func (s *Server) handleStartImport(w http.ResponseWriter, r *http.Request) { } sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } @@ -1389,7 +1486,7 @@ func (s *Server) handleImapProgress(w http.ResponseWriter, r *http.Request) { } sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } @@ -1417,7 +1514,7 @@ func (s *Server) handleSyncNow(w http.ResponseWriter, r *http.Request) { } sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } @@ -1452,7 +1549,7 @@ func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request } sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } @@ -1860,7 +1957,8 @@ func (s *Server) handleListPop3(w http.ResponseWriter, r *http.Request) { return } sess := sessionFromCtx(r.Context()) - isAdmin := sess.Role == userstore.RoleAdmin + // SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin). + isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin) accounts, err := s.pop3Store.List(r.Context(), sess.Username, isAdmin) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list POP3 accounts") @@ -1940,7 +2038,7 @@ func (s *Server) handleDeletePop3(w http.ResponseWriter, r *http.Request) { } sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } @@ -2032,7 +2130,7 @@ func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) { } sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } @@ -2068,7 +2166,7 @@ func (s *Server) handlePop3Progress(w http.ResponseWriter, r *http.Request) { } sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin { + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { writeError(w, http.StatusForbidden, "access denied") return } diff --git a/internal/smtpd/smtpd.go b/internal/smtpd/smtpd.go index 3a6c949..ce4d4a1 100644 --- a/internal/smtpd/smtpd.go +++ b/internal/smtpd/smtpd.go @@ -310,11 +310,12 @@ func (s *session) Logout() error { // ── Helpers ─────────────────────────────────────────────────────────────── -// isAllowed returns true if the IP is in the allowlist, or if the allowlist -// is empty (allow-all mode for development). +// isAllowed returns true if the IP is in the allowlist. +// SEC-26: Fail-closed — empty allowlist means NO IP is allowed (was fail-open before). +// To allow all IPs, set allowed_ips: ["0.0.0.0/0", "::/0"] explicitly in config. func (d *Daemon) isAllowed(ip string) bool { if len(d.cfg.AllowedIPs) == 0 { - return true // no restriction configured + return false // fail-closed: no IPs configured = block everything } for _, allowed := range d.cfg.AllowedIPs { // Support CIDR notation (e.g. 192.168.1.0/24)