215 lines
6.9 KiB
Go
215 lines
6.9 KiB
Go
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
|
|
} |