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
|
# App
|
||||||
PORT=8080
|
PORT=8080
|
||||||
USE_EMBEDDED=true
|
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 server paths
|
||||||
MAIL_DATA_DIR=/var/vmail
|
MAIL_DATA_DIR=/var/vmail
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,10 @@ func main() {
|
||||||
// See config.Load() for all available environment variables.
|
// See config.Load() for all available environment variables.
|
||||||
cfg := config.Load()
|
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.
|
// Override configuration with command-line flags if they were provided.
|
||||||
// Empty string means the flag was not set, so we keep the config value.
|
// Empty string means the flag was not set, so we keep the config value.
|
||||||
if *bind != "" {
|
if *bind != "" {
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,18 @@ import (
|
||||||
const MaxLoginAttempts = 5
|
const MaxLoginAttempts = 5
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
jwtManager *auth.JWTManager
|
jwtManager *auth.JWTManager
|
||||||
emailService *mail.EmailService
|
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{
|
return &AuthHandler{
|
||||||
db: database,
|
db: database,
|
||||||
jwtManager: jwtManager,
|
jwtManager: jwtManager,
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
|
trustedProxies: trustedProxies,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +58,9 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := getClientIP(c)
|
h.cleanupOldAttempts()
|
||||||
|
|
||||||
|
ip := h.getClientIP(c)
|
||||||
|
|
||||||
if isLockedOut(ip, req.Username, h.db) {
|
if isLockedOut(ip, req.Username, h.db) {
|
||||||
Error(c, http.StatusTooManyRequests, "too many failed attempts, try again later")
|
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)
|
Update("successful", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getClientIP(c *gin.Context) string {
|
func (h *AuthHandler) cleanupOldAttempts() {
|
||||||
forwarded := c.GetHeader("X-Forwarded-For")
|
cutoff := time.Now().Add(-24 * time.Hour)
|
||||||
if forwarded != "" {
|
h.db.Where("attempted_at < ? AND successful = false", cutoff).Delete(&db.ImcLoginAttempt{})
|
||||||
return strings.Split(forwarded, ",")[0]
|
}
|
||||||
}
|
|
||||||
return c.ClientIP()
|
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)
|
emailService := mail.NewEmailService(cfg)
|
||||||
|
|
||||||
r := &Router{
|
r := &Router{
|
||||||
authHandler: handlers.NewAuthHandler(database, jwtManager, emailService),
|
authHandler: handlers.NewAuthHandler(database, jwtManager, emailService, cfg.TrustedProxies),
|
||||||
domainHandler: handlers.NewDomainHandler(database),
|
domainHandler: handlers.NewDomainHandler(database),
|
||||||
userHandler: handlers.NewUserHandler(database),
|
userHandler: handlers.NewUserHandler(database),
|
||||||
aliasHandler: handlers.NewAliasHandler(database),
|
aliasHandler: handlers.NewAliasHandler(database),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
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
|
"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
|
Port string // TCP port to listen on
|
||||||
|
|
||||||
// Security settings
|
// 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)
|
// Admin user configuration (used when --reset-admin-password is not used)
|
||||||
AdminUser string // Username for the admin account
|
AdminUser string // Username for the admin account
|
||||||
|
|
@ -109,9 +112,34 @@ func Load() *Config {
|
||||||
|
|
||||||
// Application URL (for generating links in emails)
|
// Application URL (for generating links in emails)
|
||||||
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
|
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.
|
// getEnv reads an environment variable or returns a default value.
|
||||||
// key: the name of the environment variable.
|
// key: the name of the environment variable.
|
||||||
// defaultValue: the value to return if the environment variable is not set.
|
// defaultValue: the value to return if the environment variable is not set.
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
package mail
|
package mail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls" // TLS/SSL encryption for secure email sending
|
"crypto/rand" // cryptographically secure random number generator
|
||||||
"fmt" // string formatting for URLs and messages
|
"crypto/tls" // TLS/SSL encryption for secure email sending
|
||||||
"net/smtp" // simple mail transfer protocol package
|
"encoding/hex" // hex encoding for token generation
|
||||||
"strings" // string manipulation for token validation
|
"fmt" // string formatting for URLs and messages
|
||||||
|
"net/smtp" // simple mail transfer protocol package
|
||||||
|
|
||||||
"github.com/imc-vibe/backend/internal/config"
|
"github.com/imc-vibe/backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
@ -166,40 +167,24 @@ func (s *EmailService) sendWithTLS(addr, to, msg string) error {
|
||||||
return client.Quit()
|
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).
|
// 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 {
|
func GenerateToken() string {
|
||||||
// Create a byte slice to hold random bytes.
|
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
// Fill with random characters from our allowed set.
|
if _, err := rand.Read(b); err != nil {
|
||||||
// We iterate over the slice and pick characters based on position.
|
panic("crypto/rand failed: " + err.Error())
|
||||||
for i := range b {
|
|
||||||
b[i] = tokenChars[i%len(tokenChars)]
|
|
||||||
}
|
}
|
||||||
// Convert bytes to hex string. Each byte becomes two hex characters.
|
return hex.EncodeToString(b)
|
||||||
// Result: 32 bytes = 64 hex characters.
|
|
||||||
return fmt.Sprintf("%x", 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.
|
// ValidateToken checks if a token has the correct format.
|
||||||
// Returns true if the token is exactly 64 characters and contains only
|
// Returns true if the token is exactly 64 characters of valid hex.
|
||||||
// characters from our allowed tokenChars set.
|
|
||||||
// This is a quick validation before checking the database.
|
// This is a quick validation before checking the database.
|
||||||
func ValidateToken(token string) bool {
|
func ValidateToken(token string) bool {
|
||||||
// Tokens should be 64 hex characters (32 bytes * 2 hex chars/byte).
|
|
||||||
if len(token) != 64 {
|
if len(token) != 64 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Check each character is in our allowed set.
|
_, err := hex.DecodeString(token)
|
||||||
for _, c := range token {
|
return err == nil
|
||||||
if !strings.ContainsRune(tokenChars, c) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue