Files
gochat/internal/middleware/csrf.go
T
2026-06-04 15:44:48 +08:00

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
}