Files
phishingclub/backend/middleware/ratelimiter.go
T
2026-06-11 20:34:13 +02:00

126 lines
4.0 KiB
Go

package middleware
import (
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
// limiterEntry holds a rate limiter and the last time it was accessed as
// unix nanoseconds. lastAccess is accessed atomically to avoid data races
// between GetLimiter (writer) and cleanup (reader) under concurrent requests.
type limiterEntry struct {
limiter *rate.Limiter
lastAccess atomic.Int64
}
// NewIPRateLimiterMiddleware creates a middleware that limits the number of requests per IP
// limit is the number of requests per second
// burst is the maximum burst size, the maximum number of requests that can be made in a burst without being limited
func NewIPRateLimiterMiddleware(limit float64, burst int) gin.HandlerFunc {
ipLimiter := NewKeyRateLimiter(rate.Limit(limit), burst, 10*time.Minute)
return func(c *gin.Context) {
limiter := ipLimiter.GetLimiter(c.ClientIP())
if !limiter.Allow() {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
c.Next()
}
}
// NewScimRateLimiterMiddleware limits SCIM requests per company rather than per
// IP. cloud identity providers (Microsoft Entra, Okta) send every tenant's SCIM
// traffic from a shared pool of source IPs, so an IP based limit would throttle
// all companies together. keying on the companyID path param gives each company
// its own bucket. requests without a companyID fall back to the client IP.
// limit is requests per second, burst is the maximum burst size.
func NewScimRateLimiterMiddleware(limit float64, burst int) gin.HandlerFunc {
companyLimiter := NewKeyRateLimiter(rate.Limit(limit), burst, 10*time.Minute)
return func(c *gin.Context) {
key := c.Param("companyID")
if key == "" {
key = c.ClientIP()
}
limiter := companyLimiter.GetLimiter(key)
if !limiter.Allow() {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
c.Next()
}
}
// KeyRateLimiter is a rate limiter for key such as username, email or IP
type KeyRateLimiter struct {
// key is a map of key to limiterEntry
key sync.Map
// limiter is the rate limit, e.g. 1 request per second
limiter rate.Limit
// burst is the maximum burst size, the maximum number of requests that can be made in a burst without being limited
burst int
// cleanupInterval is the interval at which idle entries are evicted
cleanupInterval time.Duration
}
// NewKeyRateLimiter creates a new key rate limiter
// limiter is the rate limit, e.g. 1 request per second
// burst is the maximum burst size, the maximum number of requests that can be made in a burst without being limited
func NewKeyRateLimiter(
limiter rate.Limit,
burst int,
cleanupInterval time.Duration,
) *KeyRateLimiter {
rl := &KeyRateLimiter{
limiter: limiter,
burst: burst,
cleanupInterval: cleanupInterval,
}
go rl.cleanup()
return rl
}
// cleanup evicts entries that have not been accessed within the cleanup interval,
// preventing unbounded memory growth from the sync.Map
func (r *KeyRateLimiter) cleanup() {
for range time.Tick(r.cleanupInterval) {
threshold := time.Now().Add(-r.cleanupInterval).UnixNano()
r.key.Range(func(key, value interface{}) bool {
entry, ok := value.(*limiterEntry)
if !ok {
// remove any entry with an unexpected type
r.key.Delete(key)
return true
}
if entry.lastAccess.Load() < threshold {
r.key.Delete(key)
}
return true
})
}
}
// GetLimiter gets the limiter for a key or creates one if it does not exist
func (r *KeyRateLimiter) GetLimiter(key string) *rate.Limiter {
entry := &limiterEntry{
limiter: rate.NewLimiter(r.limiter, r.burst),
}
entry.lastAccess.Store(time.Now().UnixNano())
// LoadOrStore atomically either stores our new entry or returns the
// existing one — correctly handles both the common case and concurrent
// goroutines racing to create an entry for the same key
actual, loaded := r.key.LoadOrStore(key, entry)
if loaded {
existing := actual.(*limiterEntry)
existing.lastAccess.Store(time.Now().UnixNano())
return existing.limiter
}
return entry.limiter
}