Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user