package middleware import ( "crypto/rand" "encoding/hex" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/config" applogger "github.com/gochat/gochat/pkg/logger" ) // CSRFConfig holds CSRF protection configuration. // Reference: OWASP CSRF Prevention Cheat Sheet — double-submit cookie pattern // adapted for API-first architecture (no server-side session required). type CSRFConfig struct { Enabled bool `mapstructure:"enabled"` Secret string `mapstructure:"secret"` // 32-byte hex secret for HMAC token generation CookieName string `mapstructure:"cookie_name"` // default: "_gochat_csrf" HeaderName string `mapstructure:"header_name"` // default: X-CSRF-Token TokenLength int `mapstructure:"token_length"` // default: 32 bytes SafeMethods []string `mapstructure:"safe_methods"` // default: GET, HEAD, OPTIONS SkipPaths []string `mapstructure:"skip_paths"` // paths that skip CSRF validation entirely (e.g., /api/v1/auth/login) CookieSecure bool `mapstructure:"cookie_secure"` // set Secure flag (prod: true) CookieHTTPOnly bool `mapstructure:"cookie_http_only"` // set HttpOnly flag (default: false — JS must read for double-submit) CookieSameSite string `mapstructure:"cookie_same_site"` // Strict, Lax, or None (default: Strict) CookieDomain string `mapstructure:"cookie_domain"` // optional domain restriction CookiePath string `mapstructure:"cookie_path"` // default: / ExpirySeconds int `mapstructure:"expiry_seconds"` // token rotation period (default: 3600) } // DefaultCSRFConfig returns a secure-by-default CSRF configuration. func DefaultCSRFConfig() CSRFConfig { return CSRFConfig{ Enabled: true, CookieName: "_gochat_csrf", HeaderName: "X-CSRF-Token", TokenLength: 32, SafeMethods: []string{"GET", "HEAD", "OPTIONS"}, CookieSecure: true, CookieHTTPOnly: false, // must be readable by JS for double-submit pattern CookieSameSite: "Strict", CookiePath: "/", ExpirySeconds: 3600, } } // CSRFConfigFromAppConfig derives CSRFConfig from the global Config. func CSRFConfigFromAppConfig(cfg *config.Config) CSRFConfig { csrfCfg := DefaultCSRFConfig() csrfCfg.Enabled = cfg.CSRF.Enabled if cfg.CSRF.Secret != "" { csrfCfg.Secret = cfg.CSRF.Secret } if cfg.CSRF.CookieName != "" { csrfCfg.CookieName = cfg.CSRF.CookieName } if cfg.CSRF.HeaderName != "" { csrfCfg.HeaderName = cfg.CSRF.HeaderName } if cfg.CSRF.CookieSameSite != "" { csrfCfg.CookieSameSite = cfg.CSRF.CookieSameSite } if cfg.CSRF.CookieDomain != "" { csrfCfg.CookieDomain = cfg.CSRF.CookieDomain } if cfg.CSRF.CookiePath != "" { csrfCfg.CookiePath = cfg.CSRF.CookiePath } if cfg.CSRF.CookieSecure { csrfCfg.CookieSecure = cfg.CSRF.CookieSecure } if cfg.CSRF.CookieHTTPOnly { csrfCfg.CookieHTTPOnly = cfg.CSRF.CookieHTTPOnly } if cfg.CSRF.TokenLength > 0 { csrfCfg.TokenLength = cfg.CSRF.TokenLength } if cfg.CSRF.ExpirySeconds > 0 { csrfCfg.ExpirySeconds = cfg.CSRF.ExpirySeconds } if len(cfg.CSRF.SkipPaths) > 0 { csrfCfg.SkipPaths = cfg.CSRF.SkipPaths } // In debug mode, relax SameSite for local development if cfg.Server.Mode == "debug" { csrfCfg.CookieSameSite = "Lax" csrfCfg.CookieSecure = false } return csrfCfg } // CSRF returns a Gin middleware that implements double-submit cookie CSRF protection. // Pattern: // 1. On safe requests (GET/HEAD/OPTIONS), generate a random CSRF token and set it // as a cookie. The client JS reads this cookie and echoes it in the X-CSRF-Token header. // 2. On unsafe requests (POST/PUT/PATCH/DELETE), validate that the header value matches // the cookie value. If mismatch or missing, reject with 403. // // Reference: OWASP CSRF Prevention — DoubleSubmit Cookie pattern. // Unlike Chatwoot (Rails form_authenticity_token), this is API-first and stateless. func CSRF(cfg CSRFConfig) gin.HandlerFunc { if !cfg.Enabled { applogger.L().Info("CSRF protection disabled") return func(c *gin.Context) { c.Next() } } // Ensure we have a secret for token generation secret := cfg.Secret if secret == "" { secret = generateCSRFSecret() applogger.L().Warnf("CSRF secret not configured; using auto-generated secret (set config.csrf.secret for production)") } return func(c *gin.Context) { // Skip CSRF validation for configured paths (public auth endpoints, health checks, webhooks) path := c.Request.URL.Path for _, skip := range cfg.SkipPaths { if strings.HasPrefix(path, skip) { c.Next() return } } method := c.Request.Method // Safe methods: generate/refresh token cookie if isSafeMethod(method, cfg.SafeMethods) { token := generateCSRFToken(secret, cfg.TokenLength) setCSRFTokenCookie(c, token, cfg) c.Set("csrf_token", token) c.Next() return } // Unsafe methods: validate token cookieToken, err := c.Cookie(cfg.CookieName) if err != nil || cookieToken == "" { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "CSRF token missing from cookie", }) return } headerToken := c.GetHeader(cfg.HeaderName) if headerToken == "" { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "CSRF token missing from header", }) return } if !strings.EqualFold(cookieToken, headerToken) { applogger.L().Warnf("CSRF token mismatch: cookie=%s header=%s path=%s", cookieToken, headerToken, c.Request.URL.Path) c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "CSRF token mismatch", }) return } c.Next() } } // generateCSRFSecret creates a 32-byte random hex string for HMAC operations. func generateCSRFSecret() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { // Fallback: time-based (less secure, only for dev) applogger.L().Warnf("crypto/rand failed, using fallback secret generation: %v", err) return hex.EncodeToString([]byte("gochat-dev-csrf-secret-fallback")) } return hex.EncodeToString(b) } // generateCSRFToken creates a random CSRF token. func generateCSRFToken(secret string, length int) string { b := make([]byte, length) if _, err := rand.Read(b); err != nil { applogger.L().Warnf("CSRF token generation failed, using fallback: %v", err) return hex.EncodeToString([]byte(secret + "fallback-token")) } return hex.EncodeToString(b) } // setCSRFTokenCookie sets the CSRF token as a cookie on the response. func setCSRFTokenCookie(c *gin.Context, token string, cfg CSRFConfig) { maxAge := cfg.ExpirySeconds if maxAge <= 0 { maxAge = 3600 } c.SetCookie( cfg.CookieName, token, maxAge, cfg.CookiePath, cfg.CookieDomain, cfg.CookieSecure, cfg.CookieHTTPOnly, ) } // isSafeMethod checks if the HTTP method is considered safe (no CSRF risk). func isSafeMethod(method string, safeMethods []string) bool { for _, m := range safeMethods { if strings.EqualFold(method, m) { return true } } return false }