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
This commit is contained in:
Christoph Haas 2026-03-23 01:09:26 +01:00
parent ccaa1eca7e
commit 269fb6f53a
6 changed files with 82 additions and 46 deletions

View file

@ -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

View file

@ -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 != "" {

View file

@ -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
}

View file

@ -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),

View file

@ -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.

View file

@ -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
}