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 (
"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)