From 269fb6f53aaaa248024043c41a430e398ee32a8f Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Mon, 23 Mar 2026 01:09:26 +0100 Subject: [PATCH] Security fixes 1. Password reset tokens: use crypto/rand instead of math/rand 2. IP spoofing: only trust X-Forwarded-For from configured trusted proxies 3. Cleanup: purge failed login attempts older than 24 hours 4. JWT secret: validate minimum 32 character length on startup Updated .env.example with TRUSTED_PROXIES setting --- backend/.env.example | 3 +- backend/cmd/server/main.go | 4 +++ backend/internal/api/handlers/auth.go | 46 +++++++++++++++++++-------- backend/internal/api/router.go | 2 +- backend/internal/config/config.go | 32 +++++++++++++++++-- backend/internal/mail/email.go | 41 ++++++++---------------- 6 files changed, 82 insertions(+), 46 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 6d1fda1..bd088e4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,7 +8,8 @@ DB_NAME=mailserver # App PORT=8080 USE_EMBEDDED=true -JWT_SECRET=change-this-to-a-random-secret +JWT_SECRET=your-random-secret-at-least-32-chars +TRUSTED_PROXIES=127.0.0.1 # Mail server paths MAIL_DATA_DIR=/var/vmail diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4ab763e..11425c3 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -54,6 +54,10 @@ func main() { // See config.Load() for all available environment variables. cfg := config.Load() + if err := cfg.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + // Override configuration with command-line flags if they were provided. // Empty string means the flag was not set, so we keep the config value. if *bind != "" { diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index a675321..5dab840 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -15,16 +15,18 @@ import ( const MaxLoginAttempts = 5 type AuthHandler struct { - db *db.DB - jwtManager *auth.JWTManager - emailService *mail.EmailService + db *db.DB + jwtManager *auth.JWTManager + emailService *mail.EmailService + trustedProxies []string } -func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager, emailService *mail.EmailService) *AuthHandler { +func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager, emailService *mail.EmailService, trustedProxies []string) *AuthHandler { return &AuthHandler{ - db: database, - jwtManager: jwtManager, - emailService: emailService, + db: database, + jwtManager: jwtManager, + emailService: emailService, + trustedProxies: trustedProxies, } } @@ -56,7 +58,9 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - ip := getClientIP(c) + h.cleanupOldAttempts() + + ip := h.getClientIP(c) if isLockedOut(ip, req.Username, h.db) { Error(c, http.StatusTooManyRequests, "too many failed attempts, try again later") @@ -241,10 +245,24 @@ func clearFailedAttempts(identifier, ip string, database *db.DB) { Update("successful", true) } -func getClientIP(c *gin.Context) string { - forwarded := c.GetHeader("X-Forwarded-For") - if forwarded != "" { - return strings.Split(forwarded, ",")[0] - } - return c.ClientIP() +func (h *AuthHandler) cleanupOldAttempts() { + cutoff := time.Now().Add(-24 * time.Hour) + h.db.Where("attempted_at < ? AND successful = false", cutoff).Delete(&db.ImcLoginAttempt{}) +} + +func (h *AuthHandler) getClientIP(c *gin.Context) string { + remoteIP := c.ClientIP() + + if len(h.trustedProxies) > 0 { + for _, proxy := range h.trustedProxies { + if remoteIP == proxy { + forwarded := c.GetHeader("X-Forwarded-For") + if forwarded != "" { + return strings.Split(forwarded, ",")[0] + } + } + } + } + + return remoteIP } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 15cef10..1756d22 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -27,7 +27,7 @@ func New(database *db.DB, cfg *config.Config) *Router { emailService := mail.NewEmailService(cfg) r := &Router{ - authHandler: handlers.NewAuthHandler(database, jwtManager, emailService), + authHandler: handlers.NewAuthHandler(database, jwtManager, emailService, cfg.TrustedProxies), domainHandler: handlers.NewDomainHandler(database), userHandler: handlers.NewUserHandler(database), aliasHandler: handlers.NewAliasHandler(database), diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 7b0f531..d799f30 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -3,7 +3,9 @@ package config import ( - "os" // Standard library for reading environment variables + "fmt" // formatted error messages + "os" // Standard library for reading environment variables + "strings" "github.com/joho/godotenv" // Third-party library for loading .env files ) @@ -23,7 +25,8 @@ type Config struct { Port string // TCP port to listen on // Security settings - JWTSecret string // Secret key for signing JWT tokens (keep this secret!) + JWTSecret string // Secret key for signing JWT tokens (keep this secret!) + TrustedProxies []string // IPs of trusted reverse proxies (for X-Forwarded-For) // Admin user configuration (used when --reset-admin-password is not used) AdminUser string // Username for the admin account @@ -109,9 +112,34 @@ func Load() *Config { // Application URL (for generating links in emails) BaseURL: getEnv("BASE_URL", "http://localhost:8080"), + + // Trusted proxies (comma-separated IPs) + TrustedProxies: parseTrustedProxies(getEnv("TRUSTED_PROXIES", "")), } } +func parseTrustedProxies(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +func (c *Config) Validate() error { + if len(c.JWTSecret) < 32 { + return fmt.Errorf("JWT_SECRET must be at least 32 characters long") + } + return nil +} + // getEnv reads an environment variable or returns a default value. // key: the name of the environment variable. // defaultValue: the value to return if the environment variable is not set. diff --git a/backend/internal/mail/email.go b/backend/internal/mail/email.go index f6a7172..64e0f45 100644 --- a/backend/internal/mail/email.go +++ b/backend/internal/mail/email.go @@ -3,10 +3,11 @@ package mail import ( - "crypto/tls" // TLS/SSL encryption for secure email sending - "fmt" // string formatting for URLs and messages - "net/smtp" // simple mail transfer protocol package - "strings" // string manipulation for token validation + "crypto/rand" // cryptographically secure random number generator + "crypto/tls" // TLS/SSL encryption for secure email sending + "encoding/hex" // hex encoding for token generation + "fmt" // string formatting for URLs and messages + "net/smtp" // simple mail transfer protocol package "github.com/imc-vibe/backend/internal/config" ) @@ -166,40 +167,24 @@ func (s *EmailService) sendWithTLS(addr, to, msg string) error { return client.Quit() } -// GenerateToken creates a random token for password reset links. +// GenerateToken creates a cryptographically random token for password reset links. // Returns a 64-character hexadecimal string (32 bytes encoded as hex). -// This is cryptographically random and suitable for security-sensitive tokens. +// Uses crypto/rand for security-sensitive token generation. func GenerateToken() string { - // Create a byte slice to hold random bytes. b := make([]byte, 32) - // Fill with random characters from our allowed set. - // We iterate over the slice and pick characters based on position. - for i := range b { - b[i] = tokenChars[i%len(tokenChars)] + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) } - // Convert bytes to hex string. Each byte becomes two hex characters. - // Result: 32 bytes = 64 hex characters. - return fmt.Sprintf("%x", b) + return hex.EncodeToString(b) } -// tokenChars defines the character set used for generating reset tokens. -// Using alphanumeric characters makes tokens easier to copy/paste. -const tokenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - // ValidateToken checks if a token has the correct format. -// Returns true if the token is exactly 64 characters and contains only -// characters from our allowed tokenChars set. +// Returns true if the token is exactly 64 characters of valid hex. // This is a quick validation before checking the database. func ValidateToken(token string) bool { - // Tokens should be 64 hex characters (32 bytes * 2 hex chars/byte). if len(token) != 64 { return false } - // Check each character is in our allowed set. - for _, c := range token { - if !strings.ContainsRune(tokenChars, c) { - return false - } - } - return true + _, err := hex.DecodeString(token) + return err == nil }