* fix(security): harden auth and credential handling (HH-444) * fix(security): address HH-444 review blockers * fix(security): close remaining HH-444 review blockers --------- Co-authored-by: Rogee <rogee@ipao.vip>
95 lines
3.1 KiB
Go
95 lines
3.1 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/gochat/gochat/internal/config"
|
|
"github.com/gochat/gochat/pkg/response"
|
|
)
|
|
|
|
type securityLimitPolicy struct {
|
|
name string
|
|
limit int
|
|
window time.Duration
|
|
}
|
|
|
|
// SecurityRateLimit applies independent Redis-backed limits to credential and public ingress routes.
|
|
func SecurityRateLimit(rdb *redis.Client, cfg config.RateLimitConfig) gin.HandlerFunc {
|
|
if !cfg.Enabled {
|
|
return func(c *gin.Context) { c.Next() }
|
|
}
|
|
fallbacks := map[string]*inMemoryLimiter{}
|
|
var mu sync.Mutex
|
|
return func(c *gin.Context) {
|
|
policy, ok := securityPolicy(c.Request.Method, c.Request.URL.Path, cfg)
|
|
if !ok || policy.limit <= 0 || policy.window <= 0 {
|
|
c.Next()
|
|
return
|
|
}
|
|
key := fmt.Sprintf("gochat:security_limit:%s:%s", policy.name, c.ClientIP())
|
|
allowed, err := checkSecurityLimit(c.Request.Context(), rdb, key, policy)
|
|
if rdb == nil {
|
|
mu.Lock()
|
|
limiter := fallbacks[policy.name]
|
|
if limiter == nil {
|
|
limiter = newInMemoryLimiter(policy.limit, policy.window)
|
|
fallbacks[policy.name] = limiter
|
|
}
|
|
mu.Unlock()
|
|
allowed, _ = limiter.checkInMemory(key)
|
|
err = nil
|
|
}
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, response.APIResponse{Success: false, Error: &response.ErrorBody{Code: response.ErrInternal, Message: "rate limit service unavailable"}})
|
|
return
|
|
}
|
|
if !allowed {
|
|
c.Header("Retry-After", fmt.Sprintf("%d", int(policy.window.Seconds())))
|
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, response.APIResponse{Success: false, Error: &response.ErrorBody{Code: response.ErrRateLimit, Message: "Rate limit exceeded"}})
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func checkSecurityLimit(ctx context.Context, rdb *redis.Client, key string, policy securityLimitPolicy) (bool, error) {
|
|
if rdb == nil {
|
|
return true, nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
|
defer cancel()
|
|
count, err := redis.NewScript(`
|
|
local count = redis.call("INCR", KEYS[1])
|
|
if count == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end
|
|
return count
|
|
`).Run(ctx, rdb, []string{key}, int(policy.window.Seconds())).Int64()
|
|
return count <= int64(policy.limit), err
|
|
}
|
|
|
|
func securityPolicy(method, path string, cfg config.RateLimitConfig) (securityLimitPolicy, bool) {
|
|
toPolicy := func(name string, limit config.RouteLimitConfig) (securityLimitPolicy, bool) {
|
|
return securityLimitPolicy{name: name, limit: limit.Requests, window: time.Duration(limit.WindowSeconds) * time.Second}, true
|
|
}
|
|
if method == http.MethodPost && (path == "/auth/sign_in" || path == "/api/v1/auth/login") {
|
|
return toPolicy("login", cfg.Login)
|
|
}
|
|
if (method == http.MethodPost || method == http.MethodPut) && (path == "/auth/password" || path == "/api/v1/auth/reset_password") {
|
|
return toPolicy("password_reset", cfg.PasswordReset)
|
|
}
|
|
if strings.HasPrefix(path, "/webhooks/") {
|
|
return toPolicy("webhook", cfg.Webhook)
|
|
}
|
|
if strings.Contains(path, "/upload") || strings.Contains(path, "/direct_uploads") {
|
|
return toPolicy("public_upload", cfg.PublicUpload)
|
|
}
|
|
return securityLimitPolicy{}, false
|
|
}
|