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:
+29
-5
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
+115
-17
@@ -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,17 +1062,19 @@ 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 {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to parse mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
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) {
|
||||||
writeError(w, http.StatusForbidden, "access denied")
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "message/rfc822")
|
w.Header().Set("Content-Type", "message/rfc822")
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.eml"`, id[:16]))
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.eml"`, id[:16]))
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user