Files
gochat/backend/internal/middleware/security_rate_limit.go
T
Rogeeandrogee f719529d66 fix(security): harden auth and secret handling (HH-444) (#101)
* 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>
2026-08-22 15:45:06 +08:00

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
}