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
+29 -5
View File
@@ -3,9 +3,11 @@ package main
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"flag" "flag"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -14,6 +16,8 @@ import (
"syscall" "syscall"
"time" "time"
"golang.org/x/crypto/hkdf"
"github.com/archivmail/config" "github.com/archivmail/config"
"github.com/archivmail/internal/api" "github.com/archivmail/internal/api"
"github.com/archivmail/internal/audit" "github.com/archivmail/internal/audit"
@@ -67,6 +71,26 @@ func main() {
os.Exit(1) 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 // Storage with encryption + DB metadata
storeCfg := storage.Config{ storeCfg := storage.Config{
Dir: cfg.Storage.StorePath, Dir: cfg.Storage.StorePath,
@@ -127,7 +151,7 @@ func main() {
} }
// LDAP config store // LDAP config store
ldapSt, err := ldapcfg.New(cfg.Database.DSN(), cfg.API.Secret) ldapSt, err := ldapcfg.New(cfg.Database.DSN(), aesKey)
if err != nil { if err != nil {
logger.Error("ldap config store init failed", "err", err) logger.Error("ldap config store init failed", "err", err)
os.Exit(1) os.Exit(1)
@@ -135,12 +159,12 @@ func main() {
defer ldapSt.Close() defer ldapSt.Close()
// Auth manager (with LDAP fallback) // Auth manager (with LDAP fallback)
authMgr := auth.New(users, ldapSt, cfg.API.Secret) authMgr := auth.New(users, ldapSt, jwtSecret)
// API server // API server
apiCfg := config.APIConfig{ apiCfg := config.APIConfig{
Bind: cfg.API.Bind, Bind: cfg.API.Bind,
Secret: cfg.API.Secret, Secret: jwtSecret,
} }
srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger) srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger)
@@ -200,7 +224,7 @@ func main() {
srv.SetSMTPDaemon(smtpDaemon) srv.SetSMTPDaemon(smtpDaemon)
// IMAP store + importer + scheduler (wired to use async worker) // 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 { if err != nil {
logger.Error("imap store init failed", "err", err) logger.Error("imap store init failed", "err", err)
os.Exit(1) os.Exit(1)
@@ -213,7 +237,7 @@ func main() {
srv.SetImap(imapSt, imapImp, imapSched) srv.SetImap(imapSt, imapImp, imapSched)
// POP3 store + importer // POP3 store + importer
pop3St, err := pop3store.New(cfg.Database.DSN(), cfg.API.Secret) pop3St, err := pop3store.New(cfg.Database.DSN(), aesKey)
if err != nil { if err != nil {
logger.Error("pop3 store init failed", "err", err) logger.Error("pop3 store init failed", "err", err)
os.Exit(1) os.Exit(1)
+3
View File
@@ -56,6 +56,9 @@ func (d DatabaseConfig) DSN() string {
} }
// SMTPConfig holds settings for the embedded SMTP server. // 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 { type SMTPConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Bind string `yaml:"bind"` Bind string `yaml:"bind"`
+2 -1
View File
@@ -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-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-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-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 |
<!-- Add features above this line --> <!-- Add features above this line -->
## Next Available ID: PROJ-23 ## Next Available ID: PROJ-24
+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) { func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") 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) raw, err := s.store.Load(id)
if err != nil { if err != nil {
@@ -340,8 +345,18 @@ func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
return return
} }
// User role: only own mailbox
sess := sessionFromCtx(r.Context()) 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 { if sess.Role == userstore.RoleUser {
u, err := s.users.GetByUsername(sess.Username) u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) { if err != nil || !mailBelongsToUser(pm, u.Email) {
@@ -394,8 +409,35 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
return 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()) 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 // For RoleUser, look up user email once for access checks
var userEmail string var userEmail string
if sess.Role == userstore.RoleUser { if sess.Role == userstore.RoleUser {
+119 -21
View File
@@ -17,6 +17,8 @@ import (
"syscall" "syscall"
"time" "time"
"regexp"
"github.com/archivmail/config" "github.com/archivmail/config"
"github.com/archivmail/internal/audit" "github.com/archivmail/internal/audit"
"github.com/archivmail/internal/auth" "github.com/archivmail/internal/auth"
@@ -31,6 +33,27 @@ import (
"github.com/archivmail/pkg/mailparser" "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 type contextKey string
const ( 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/system/stats", s.authAdmin(s.handleSystemStats))
s.mux.HandleFunc("GET /api/admin/security/audit", s.authAdmin(s.handleSecurityAudit)) 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 // Export routes
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF))) 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 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{ user, err := s.users.Create(userstore.CreateUserRequest{
Username: req.Username, Username: req.Username,
Email: req.Email, Email: req.Email,
Password: req.Password, Password: req.Password,
Role: req.Role, Role: req.Role,
TenantID: tenantID,
}) })
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{ s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt, EventType: audit.EventUserMgmt,
Username: sess.Username, Username: sess.Username,
@@ -397,6 +436,33 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return 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{ updated, err := s.users.Update(id, userstore.UpdateUserRequest{
Email: req.Email, Email: req.Email,
Role: req.Role, Role: req.Role,
@@ -408,7 +474,6 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{ s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt, EventType: audit.EventUserMgmt,
Username: sess.Username, Username: sess.Username,
@@ -440,6 +505,21 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
return 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 := s.users.DeleteSafe(id); err != nil {
if err.Error() == "userstore: cannot delete last admin" { if err.Error() == "userstore: cannot delete last admin" {
writeError(w, http.StatusConflict, "cannot delete the last active 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{ s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt, EventType: audit.EventUserMgmt,
Username: sess.Username, 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) { func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") 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) raw, err := s.store.Load(id)
if err != nil { 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) { func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") 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") indexStr := r.PathValue("index")
idx, err := strconv.Atoi(indexStr) idx, err := strconv.Atoi(indexStr)
if err != nil { 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) { func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") 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) raw, err := s.store.Load(id)
if err != nil { 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 { if sess.Role == userstore.RoleUser {
pm, err := mailparser.Parse(raw) pm, err := mailparser.Parse(raw)
if err == nil { if err != nil {
u, err := s.users.GetByUsername(sess.Username) writeError(w, http.StatusInternalServerError, "failed to parse mail")
if err != nil || !mailBelongsToUser(pm, u.Email) { return
writeError(w, http.StatusForbidden, "access denied") }
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 return
} }
sess := sessionFromCtx(r.Context()) 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) accounts, err := s.imapStore.List(r.Context(), sess.Username, isAdmin)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list IMAP accounts") 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()) 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") writeError(w, http.StatusForbidden, "access denied")
return return
} }
@@ -1353,7 +1450,7 @@ func (s *Server) handleStartImport(w http.ResponseWriter, r *http.Request) {
} }
sess := sessionFromCtx(r.Context()) 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") writeError(w, http.StatusForbidden, "access denied")
return return
} }
@@ -1389,7 +1486,7 @@ func (s *Server) handleImapProgress(w http.ResponseWriter, r *http.Request) {
} }
sess := sessionFromCtx(r.Context()) 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") writeError(w, http.StatusForbidden, "access denied")
return return
} }
@@ -1417,7 +1514,7 @@ func (s *Server) handleSyncNow(w http.ResponseWriter, r *http.Request) {
} }
sess := sessionFromCtx(r.Context()) 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") writeError(w, http.StatusForbidden, "access denied")
return return
} }
@@ -1452,7 +1549,7 @@ func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request
} }
sess := sessionFromCtx(r.Context()) 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") writeError(w, http.StatusForbidden, "access denied")
return return
} }
@@ -1860,7 +1957,8 @@ func (s *Server) handleListPop3(w http.ResponseWriter, r *http.Request) {
return return
} }
sess := sessionFromCtx(r.Context()) 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) accounts, err := s.pop3Store.List(r.Context(), sess.Username, isAdmin)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list POP3 accounts") 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()) 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") writeError(w, http.StatusForbidden, "access denied")
return return
} }
@@ -2032,7 +2130,7 @@ func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) {
} }
sess := sessionFromCtx(r.Context()) 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") writeError(w, http.StatusForbidden, "access denied")
return return
} }
@@ -2068,7 +2166,7 @@ func (s *Server) handlePop3Progress(w http.ResponseWriter, r *http.Request) {
} }
sess := sessionFromCtx(r.Context()) 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") writeError(w, http.StatusForbidden, "access denied")
return return
} }
+4 -3
View File
@@ -310,11 +310,12 @@ func (s *session) Logout() error {
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
// isAllowed returns true if the IP is in the allowlist, or if the allowlist // isAllowed returns true if the IP is in the allowlist.
// is empty (allow-all mode for development). // 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 { func (d *Daemon) isAllowed(ip string) bool {
if len(d.cfg.AllowedIPs) == 0 { 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 { for _, allowed := range d.cfg.AllowedIPs {
// Support CIDR notation (e.g. 192.168.1.0/24) // Support CIDR notation (e.g. 192.168.1.0/24)