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:
parent
ccaa1eca7e
commit
269fb6f53a
6 changed files with 82 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue