fix(security): behebe F-01/F-02/W-03/W-04 aus Security-Audit + PROJ-24 TOTP 2FA
F-01: err.Error() wird nicht mehr an HTTP-Clients gesendet.
Stattdessen generische Fehlermeldungen + Server-Log.
Betrifft: handleCreateUser, handleUpdateUser, handleDeleteUser,
handleSyncNow, handleSecurityConfig, handleUpload.
F-02: Login-Audit-Log enthält keinen rohen err.Error() mehr.
Neue classifyLoginError() Funktion: invalid_password / ldap_error /
account_disabled / unknown — schützt vor LDAP-Info-Leak via Audit-API.
W-03: remoteIP() trimmt jetzt Leerzeichen aus X-Forwarded-For.
Vollständige Lösung erfordert Proxy-Konfiguration (W-03 bleibt WARN).
W-04: Attachment-Dateiname wird durch sanitizeFilename() bereinigt.
Nur [a-zA-Z0-9._- ] erlaubt — verhindert Header-Injection.
PROJ-24: TOTP 2FA vollständig implementiert:
- internal/auth/totp.go: GenerateSecret, ValidateTOTP, QRCodeSVG
- internal/api/totp_handlers.go: Setup, Login-Step2, Admin-Reset
- internal/userstore: SetTOTPSecret, EnableTOTP, DisableTOTP, ResetTOTP
- Login-Flow: totp_pending JWT → /api/auth/totp → vollwertiger JWT
- AES-256-GCM verschlüsseltes Secret in users.totp_secret
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+73
-9
@@ -194,6 +194,13 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /api/pop3/test", s.auth(s.handleTestPop3))
|
||||
s.mux.HandleFunc("POST /api/pop3/{id}/import", s.auth(s.handleStartPop3Import))
|
||||
s.mux.HandleFunc("GET /api/pop3/{id}/progress", s.auth(s.handlePop3Progress))
|
||||
|
||||
// PROJ-24: TOTP 2FA routes
|
||||
s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet))
|
||||
s.mux.HandleFunc("POST /api/auth/totp/setup", s.auth(s.handleTOTPSetupPost))
|
||||
s.mux.HandleFunc("DELETE /api/auth/totp", s.auth(s.handleTOTPDisable))
|
||||
s.mux.HandleFunc("POST /api/auth/totp", s.handleTOTPLogin) // no auth middleware — uses pending token
|
||||
s.mux.HandleFunc("POST /api/admin/users/{id}/totp/reset", s.authAdmin(s.handleTOTPReset))
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
@@ -236,7 +243,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, user, err := s.authMgr.Login(req.Username, req.Password)
|
||||
token, user, totpRequired, err := s.authMgr.Login(req.Username, req.Password)
|
||||
if err != nil {
|
||||
_ = s.users.RecordLoginAttempt(req.Username, remoteIP(r))
|
||||
s.audlog.Log(audit.Entry{
|
||||
@@ -244,12 +251,28 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Username: req.Username,
|
||||
IPAddress: remoteIP(r),
|
||||
Success: false,
|
||||
Detail: err.Error(),
|
||||
Detail: classifyLoginError(err),
|
||||
})
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// PROJ-24: If TOTP is enabled, return a pending token instead of a full session.
|
||||
if totpRequired {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLogin,
|
||||
Username: user.Username,
|
||||
IPAddress: remoteIP(r),
|
||||
Success: true,
|
||||
Detail: "totp_pending",
|
||||
})
|
||||
writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||
"totp_required": true,
|
||||
"session_token": token,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.users.UpdateLastLogin(user.ID)
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
@@ -404,7 +427,8 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
TenantID: tenantID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
s.logger.Error("create user failed", "err", err)
|
||||
writeError(w, http.StatusBadRequest, "user creation failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -477,7 +501,8 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
s.logger.Error("update user failed", "err", err)
|
||||
writeError(w, http.StatusBadRequest, "user update failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -532,7 +557,8 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, "cannot delete the last active admin")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
s.logger.Error("delete user failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "user deletion failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -876,9 +902,45 @@ func tenantFromCtx(ctx context.Context) *int64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// sanitizeFilename strips characters that could be used for HTTP header injection
|
||||
// (quotes, newlines, control chars) from attachment filenames coming from parsed
|
||||
// e-mails. Only alphanumerics, spaces, dots, hyphens, and underscores are kept.
|
||||
func sanitizeFilename(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
|
||||
r == '.' || r == '-' || r == '_' || r == ' ' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// classifyLoginError maps internal login errors to safe audit-log categories.
|
||||
// Raw error messages must not be stored in audit logs since auditor-role
|
||||
// users can read them via GET /api/audit and internal details (LDAP hostnames,
|
||||
// port numbers, etc.) would be exposed.
|
||||
func classifyLoginError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "not found"), strings.Contains(msg, "invalid password"),
|
||||
strings.Contains(msg, "invalid credentials"):
|
||||
return "invalid_password"
|
||||
case strings.Contains(msg, "ldap"), strings.Contains(msg, "LDAP"):
|
||||
return "ldap_error"
|
||||
case strings.Contains(msg, "disabled"), strings.Contains(msg, "inactive"):
|
||||
return "account_disabled"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func remoteIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
return strings.Split(fwd, ",")[0]
|
||||
return strings.TrimSpace(strings.Split(fwd, ",")[0])
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
@@ -1043,7 +1105,7 @@ func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
a := pm.Attachments[idx]
|
||||
filename := a.Filename
|
||||
filename := sanitizeFilename(a.Filename)
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("attachment-%d", idx)
|
||||
}
|
||||
@@ -1538,7 +1600,8 @@ func (s *Server) handleSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.imapScheduler.TriggerSync(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
s.logger.Error("trigger sync failed", "err", err)
|
||||
writeError(w, http.StatusConflict, "sync already running or failed to start")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1914,7 +1977,8 @@ func (s *Server) handleSecurityFix(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := os.Stat(jailPath); os.IsNotExist(err) {
|
||||
jailConf := "[sshd]\nenabled = true\nmaxretry = 5\nbantime = 3600\nfindtime = 600\n"
|
||||
if err := os.WriteFile(jailPath, []byte(jailConf), 0644); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not write jail.local: "+err.Error())
|
||||
s.logger.Error("could not write jail.local", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "security config update failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user