The login rate limiter keys its buckets on the raw client-supplied X-Forwarded-For header, with no
trusted-proxy check and no validation. Sending a different value per request gives an attacker a fresh
bucket every time, so the 5-failures-per-minute limit never triggers. The same header also makes the
failure map grow without bound.
Environment
- CubeSandbox version / commit:
5960c56 (master)
- Host OS and kernel version: any
- KVM info (
modinfo kvm): n/a
- Deployment mode: single-node / cluster
- Relevant component: CubeOps
Steps to Reproduce
for i in $(seq 1 50); do
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST http://<cubeops>/api/v1/auth/login \
-H "X-Forwarded-For: 10.0.0.$i" \
-d '{"username":"admin","password":"guess-'"$i"'"}'
done
Expected Behavior
After 5 failures from the same client, further attempts return 429.
Actual Behavior
All 50 return 401 — the limiter never fires. Reproduced against the real handler:
no XFF, same peer IP : [401 401 401 401 401 429 429 429 429 429] <- baseline works
rotating XFF, same peer IP : 50 attempts -> 50 x 401, 0 x 429
whitespace-padded same IP : 20 attempts -> 0 x 429
The third case matters on its own: " 198.51.100.7" and "198.51.100.7" land in different buckets, so
even a single address yields unlimited buckets by varying leading whitespace.
Additional Context
CubeOps/internal/auth/ratelimit.go:87-98:
func clientIP(c *gin.Context) string {
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
for i := 0; i < len(xff); i++ {
if xff[i] == ',' { return xff[:i] }
}
return xff // caller-controlled, unvalidated, untrimmed
}
return c.ClientIP()
}
This reads the header directly, bypassing gin's SetTrustedProxies / RemoteIPHeaders machinery
entirely. The module comment states the limiter "protects the weak default credentials (admin/admin) from
brute-force attacks" — it does not.
Second defect, same file. The comments at :36-38 and :61-62 claim pruning prevents "unbounded
memory growth from IPs that never return after their window expires". It does not: recordFailure and
isBlocked prune only the key currently being touched, and there is no background sweeper. N distinct
header values create N permanent map entries, each holding a []time.Time — remote memory exhaustion on
an unauthenticated endpoint.
The login rate limiter keys its buckets on the raw client-supplied
X-Forwarded-Forheader, with notrusted-proxy check and no validation. Sending a different value per request gives an attacker a fresh
bucket every time, so the 5-failures-per-minute limit never triggers. The same header also makes the
failure map grow without bound.
Environment
5960c56(master)modinfo kvm): n/aSteps to Reproduce
Expected Behavior
After 5 failures from the same client, further attempts return
429.Actual Behavior
All 50 return
401— the limiter never fires. Reproduced against the real handler:The third case matters on its own:
" 198.51.100.7"and"198.51.100.7"land in different buckets, soeven a single address yields unlimited buckets by varying leading whitespace.
Additional Context
CubeOps/internal/auth/ratelimit.go:87-98:This reads the header directly, bypassing gin's
SetTrustedProxies/RemoteIPHeadersmachineryentirely. The module comment states the limiter "protects the weak default credentials (admin/admin) from
brute-force attacks" — it does not.
Second defect, same file. The comments at
:36-38and:61-62claim pruning prevents "unboundedmemory growth from IPs that never return after their window expires". It does not:
recordFailureandisBlockedprune only the key currently being touched, and there is no background sweeper. N distinctheader values create N permanent map entries, each holding a
[]time.Time— remote memory exhaustion onan unauthenticated endpoint.