Skip to content

[Bug Report] CubeOps login rate limiter is bypassable with a spoofed X-Forwarded-For, and its failure map is unbounded #1377

Description

@dwin-gharibi

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.

Metadata

Metadata

Assignees

Labels

area/CubeMasterImpacts the CubeMatser (control plane)bugSomething isn't workingneeds-triage

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions