diff --git a/config/config.go b/config/config.go index c03b064..8262ff4 100644 --- a/config/config.go +++ b/config/config.go @@ -9,8 +9,14 @@ import ( // APIConfig holds configuration for the HTTP API server. type APIConfig struct { - Bind string `yaml:"bind"` - Secret string `yaml:"secret"` + Bind string `yaml:"bind"` + Secret string `yaml:"secret"` + // SecureCookies sets the Secure flag on session cookies. + // Enable when TLS is terminated at this server or at a trusted reverse proxy. + SecureCookies bool `yaml:"secure_cookies"` + // TrustedProxies is a list of IP addresses or CIDR ranges whose + // X-Forwarded-For header is trusted. Empty = trust no proxy (use r.RemoteAddr). + TrustedProxies []string `yaml:"trusted_proxies"` } // Config is the full application configuration loaded from YAML. diff --git a/internal/api/export.go b/internal/api/export.go index cf52cf9..47d9d9f 100644 --- a/internal/api/export.go +++ b/internal/api/export.go @@ -382,7 +382,7 @@ func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventExport, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), MailID: id, Detail: "pdf", Success: true, @@ -551,7 +551,7 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventExport, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Detail: fmt.Sprintf("zip: %d mails", exported), Success: true, }) diff --git a/internal/api/ldap_tenants.go b/internal/api/ldap_tenants.go index fd813c2..ad8e167 100644 --- a/internal/api/ldap_tenants.go +++ b/internal/api/ldap_tenants.go @@ -81,7 +81,7 @@ func (s *Server) handleSaveLDAP(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: "ldap_config_saved", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "LDAP-Konfiguration gespeichert", }) @@ -109,7 +109,7 @@ func (s *Server) handleDeleteLDAP(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: "ldap_config_deleted", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "LDAP-Konfiguration gelöscht", }) @@ -167,7 +167,7 @@ func (s *Server) handleTestLDAP(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: "ldap_connection_test", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: result.OK, Detail: result.Message, }) @@ -218,7 +218,7 @@ func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: "tenant_created", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant erstellt: " + req.Name, }) @@ -308,7 +308,7 @@ func (s *Server) handleDeleteTenant(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: "tenant_deleted", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant gelöscht: " + strconv.FormatInt(id, 10), }) @@ -469,7 +469,7 @@ func (s *Server) handleSaveTenantLDAP(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_config_saved", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-LDAP-Konfiguration gespeichert", }) @@ -500,7 +500,7 @@ func (s *Server) handleDeleteTenantLDAP(w http.ResponseWriter, r *http.Request) s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_config_deleted", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-LDAP-Konfiguration gelöscht", }) @@ -539,7 +539,7 @@ func (s *Server) handleTestTenantLDAP(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_connection_test", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: result.OK, Detail: result.Message, }) @@ -611,7 +611,7 @@ func (s *Server) handleAdminSaveTenantLDAP(w http.ResponseWriter, r *http.Reques s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_config_saved", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-LDAP-Konfiguration gespeichert (tenant " + strconv.FormatInt(id, 10) + ")", }) @@ -643,7 +643,7 @@ func (s *Server) handleAdminDeleteTenantLDAP(w http.ResponseWriter, r *http.Requ s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_config_deleted", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-LDAP-Konfiguration gelöscht (tenant " + strconv.FormatInt(id, 10) + ")", }) @@ -683,7 +683,7 @@ func (s *Server) handleAdminTestTenantLDAP(w http.ResponseWriter, r *http.Reques s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_connection_test", Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: result.OK, Detail: result.Message + " (tenant " + strconv.FormatInt(id, 10) + ")", }) diff --git a/internal/api/profile_handlers.go b/internal/api/profile_handlers.go index b3f1a1b..86d9ef0 100644 --- a/internal/api/profile_handlers.go +++ b/internal/api/profile_handlers.go @@ -82,7 +82,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "change_password", }) @@ -143,7 +143,7 @@ func (s *Server) handleChangeEmail(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "change_email", }) diff --git a/internal/api/server.go b/internal/api/server.go index 3482706..17f8685 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "math" + "net" "net/http" "os" "os/exec" @@ -239,7 +240,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: req.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: false, Detail: "rate limited", }) @@ -249,11 +250,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { token, user, totpRequired, err := s.authMgr.Login(req.Username, req.Password) if err != nil { - _ = s.users.RecordLoginAttempt(req.Username, remoteIP(r)) + _ = s.users.RecordLoginAttempt(req.Username, s.remoteIP(r)) s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: req.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: false, Detail: classifyLoginError(err), }) @@ -266,7 +267,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: user.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "totp_pending", }) @@ -282,7 +283,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: user.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, }) @@ -293,7 +294,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { MaxAge: 8 * 3600, HttpOnly: true, SameSite: http.SameSiteStrictMode, - // Secure: true — enable when TLS is terminated at this server + Secure: s.cfg.SecureCookies, }) writeJSON(w, http.StatusOK, map[string]interface{}{ @@ -343,13 +344,14 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, + Secure: s.cfg.SecureCookies, }) sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventLogout, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, }) @@ -439,7 +441,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Detail: "created user: " + user.Username, Success: true, }) @@ -513,7 +515,7 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Detail: fmt.Sprintf("updated user %d", id), Success: true, }) @@ -579,7 +581,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Detail: fmt.Sprintf( "deleted user %d (%s, role=%s); %d IMAP account(s) removed; emails retained per GoBD", id, target.Username, target.Role, imapDeleted, @@ -693,7 +695,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventSearch, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Query: q, Success: true, }) @@ -942,11 +944,37 @@ func classifyLoginError(err error) string { } } -func remoteIP(r *http.Request) string { - if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { - return strings.TrimSpace(strings.Split(fwd, ",")[0]) +// remoteIP returns the real client IP. +// X-Forwarded-For is only trusted when the direct connection comes from a +// configured trusted proxy. Otherwise r.RemoteAddr is used directly to prevent +// clients from spoofing their IP in audit logs and rate-limit counters. +func (s *Server) remoteIP(r *http.Request) string { + directIP, _, _ := net.SplitHostPort(r.RemoteAddr) + if directIP == "" { + directIP = r.RemoteAddr } - return r.RemoteAddr + if len(s.cfg.TrustedProxies) > 0 && isTrustedProxy(directIP, s.cfg.TrustedProxies) { + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + return strings.TrimSpace(strings.Split(fwd, ",")[0]) + } + } + return directIP +} + +// isTrustedProxy checks whether ip is in the list of trusted proxy addresses/CIDRs. +func isTrustedProxy(ip string, proxies []string) bool { + parsed := net.ParseIP(ip) + for _, p := range proxies { + if strings.Contains(p, "/") { + _, cidr, err := net.ParseCIDR(p) + if err == nil && cidr.Contains(parsed) { + return true + } + } else if p == ip { + return true + } + } + return false } // ── Mail access middleware ──────────────────────────────────────────────── @@ -1324,7 +1352,7 @@ func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: "service." + body.Action, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Detail: name, Success: true, }) @@ -1346,7 +1374,7 @@ func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: "service." + body.Action, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Detail: name, Success: true, }) diff --git a/internal/api/totp_handlers.go b/internal/api/totp_handlers.go index 4022849..8285e24 100644 --- a/internal/api/totp_handlers.go +++ b/internal/api/totp_handlers.go @@ -103,7 +103,7 @@ func (s *Server) handleTOTPSetupPost(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Detail: "totp_enabled", Success: true, }) @@ -156,7 +156,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Detail: "totp_disabled", Success: true, }) @@ -180,7 +180,7 @@ func (s *Server) handleTOTPLogin(w http.ResponseWriter, r *http.Request) { if err != nil { s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: false, Detail: "totp_login_failed: " + err.Error(), }) @@ -193,7 +193,7 @@ func (s *Server) handleTOTPLogin(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: user.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Success: true, Detail: "totp_login_completed", }) @@ -205,6 +205,7 @@ func (s *Server) handleTOTPLogin(w http.ResponseWriter, r *http.Request) { MaxAge: 8 * 3600, HttpOnly: true, SameSite: http.SameSiteStrictMode, + Secure: s.cfg.SecureCookies, }) writeJSON(w, http.StatusOK, map[string]interface{}{ @@ -253,7 +254,7 @@ func (s *Server) handleTOTPReset(w http.ResponseWriter, r *http.Request) { s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, - IPAddress: remoteIP(r), + IPAddress: s.remoteIP(r), Detail: fmt.Sprintf("totp_reset_by_admin: TOTP reset by %s for user %s (id=%d)", sess.Username, target.Username, id), Success: true, })