Gin's trusted-proxies is never configured, letting X-Forwarded-For defeat every per-IP rate limit and IP ban
Affected versions: confirmed on commit 3048a49 (current main).
Summary
coai's Gin engine is constructed without ever calling SetTrustedProxies, so Gin trusts X-Forwarded-For/X-Real-IP from any client by default, not just from a configured reverse proxy. Every per-IP security control in the codebase (the login/register/verify/reset rate limiter, and the API-key brute-force IP ban list) derives the caller's identity from c.ClientIP(), so an attacker who connects directly can set an arbitrary X-Forwarded-For value and change it on every request to make each request appear to come from a different IP, fully sidestepping IP-based throttling and banning. This is most severe against /reset (password reset by email), whose 6-digit verification code is otherwise only protected by a per-IP rate limit.
Details
utils/bootstrap.go, before the fix:
func NewEngine() *gin.Engine {
if viper.GetBool("debug") {
return gin.Default()
}
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(gin.Recovery())
return engine
}No file in the repository calls SetTrustedProxies. Gin's own documentation states that when this is unset, it trusts X-Forwarded-For/X-Real-IP unconditionally.
middleware/throttle.go keys every rate limit off c.ClientIP():
func ThrottleMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
path := c.Request.URL.Path
cache := utils.GetCacheFromContext(c)
limiter := GetPrefixMap[Limiter](path, limits)
if limiter != nil {
rate, err := limiter.RateLimit(cache, ip, path)
...with /reset limited to 10 requests per 120 seconds per (spoofable) IP:
var limits = map[string]Limiter{
"/login": {Duration: 10, Count: 20},
"/register": {Duration: 120, Count: 10},
"/verify": {Duration: 120, Count: 10},
"/reset": {Duration: 120, Count: 10},
...auth/auth.go's reset code is 6 numeric digits with a 5-minute TTL:
func generateCode(c *gin.Context, cache *redis.Client, email string) string {
code := utils.GenerateCode(6)
setCode(c, cache, email, code)
return code
}
func setCode(c *gin.Context, cache *redis.Client, email, code string) {
cache.Set(c, fmt.Sprintf("nio:otp:%s", email), code, 5*time.Minute)
}middleware/auth.go's API-key brute-force protection has the same c.ClientIP() dependency:
func ProcessKey(c *gin.Context, key string) *auth.User {
addr := c.ClientIP()
cache := utils.GetCacheFromContext(c)
if utils.IsInBlackList(cache, addr) {
...
}
if user := auth.ParseApiKey(c, key); user != nil {
...
}
utils.IncrIP(cache, addr)
...Since c.ClientIP() trusts an attacker-supplied X-Forwarded-For unconditionally, every one of these checks can be made to treat each request as coming from a fresh, never-before-seen IP simply by changing the header value each time.
POC
(available upon request)
Impact
An attacker with direct network access to the server (the default, documented Docker Compose deployment exposes the Go binary's port directly, with no reverse proxy in front of it) can set X-Forwarded-For to a different value on every request and:
- Brute-force the 6-digit, 5-minute-lived
/resetemail verification code (1,000,000 possibilities, effectively unthrottled), resetting the password of, and taking over, any account by knowing only its email address. - Brute-force the
/loginpassword rate limit and the API-key brute-force IP ban list the same way.
Source: coaidev/coai