fix(security): W-02 Secure-Cookie-Flag + W-03 TrustedProxies für X-Forwarded-For

W-02: Cookie Secure-Flag ist nun über config.yml steuerbar.
      api.secure_cookies: true/false — default false (kein Breaking Change).
      Alle 3 SetCookie-Aufrufe (Login, Logout, TOTP) nutzen s.cfg.SecureCookies.

W-03: remoteIP() ist jetzt eine Methode und prüft api.trusted_proxies.
      X-Forwarded-For wird nur ausgewertet wenn der direkte Peer in der
      trusted_proxies-Liste steht (IP oder CIDR). Sonst wird r.RemoteAddr
      verwendet — kein Spoofing mehr möglich.
      Neue Hilfsfunktion: isTrustedProxy(ip, proxies).

config.go: APIConfig um SecureCookies bool + TrustedProxies []string erweitert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-18 01:10:24 +01:00
parent 280034679e
commit 0fbb1924bb
6 changed files with 74 additions and 39 deletions
+45 -17
View File
@@ -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,
})