Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CubeOps/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ log_file_num: 10 # number of rotated log files to retain
log_file_size: 100 # max size in MB per log file before rotation
jwt_secret: "" # leave empty to auto-generate on first start

# Reverse proxies allowed to assert the client IP via X-Forwarded-For.
# Empty (the default) trusts none: the login rate limiter keys on the real TCP
# peer, which is correct for direct exposure. Behind nginx/an LB, list the
# proxy here as an IP or CIDR, otherwise every client shares one bucket.
# Keep this as narrow as possible -- any peer whose source address matches is
# trusted to assert any client IP, so a broad range such as 10.0.0.0/8 hands
# that power to every workload on the network. 0.0.0.0/0 and ::/0 are rejected.
# Env override: CUBE_OPS_TRUSTED_PROXIES (comma-separated).
trusted_proxies: []

# --- Database (MySQL) ---
# IMPORTANT: Replace the placeholder values below with your real credentials
# before deploying. The defaults shown here are EXAMPLES ONLY and will not
Expand Down
69 changes: 52 additions & 17 deletions CubeOps/internal/auth/ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package auth

import (
"net/http"
"sort"
"sync"
"time"

Expand All @@ -26,6 +27,11 @@ type loginLimiter struct {
window time.Duration
}

const (
sweepThreshold = 4096
evictTarget = sweepThreshold / 2
)

var defaultLoginLimiter = &loginLimiter{
failures: make(map[string][]time.Time),
limit: 5, // 5 failed attempts
Expand All @@ -41,6 +47,12 @@ func (l *loginLimiter) recordFailure(ip string) {
defer l.mu.Unlock()
now := time.Now()
cutoff := now.Add(-l.window)
if len(l.failures) >= sweepThreshold {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sweepThreshold is a sweep trigger, not a cap. The sweep only removes expired entries, so during an ongoing distributed attack (all entries live, within the window) the map keeps growing past 4096 and every subsequent recordFailure pays a full O(map) sweep while holding l.mu — CPU + lock-contention amplification under exactly the attack this limiter exists to blunt. The PR description's claim that "the map stays at or below sweepThreshold" holds only once entries expire. Consider evicting when the map exceeds the threshold (not just sweeping), or document that this bounds steady-state memory rather than peak.

l.sweepLocked(cutoff)
if len(l.failures) >= sweepThreshold {
l.evictOldestLocked(evictTarget)
}
}
fails := l.failures[ip]
// Drop expired entries.
kept := fails[:0]
Expand All @@ -57,6 +69,44 @@ func (l *loginLimiter) recordFailure(ip string) {
}
}

func (l *loginLimiter) sweepLocked(cutoff time.Time) {
for ip, fails := range l.failures {
kept := fails[:0]
for _, t := range fails {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) == 0 {
delete(l.failures, ip)
} else {
l.failures[ip] = kept
}
}
}

func (l *loginLimiter) evictOldestLocked(target int) {
type entry struct {
ip string
last time.Time
}
entries := make([]entry, 0, len(l.failures))
for ip, fails := range l.failures {
if len(fails) == 0 {
delete(l.failures, ip)
continue
}
entries = append(entries, entry{ip: ip, last: fails[len(fails)-1]})
}
if len(entries) <= target {
return
}
sort.Slice(entries, func(i, j int) bool { return entries[i].last.Before(entries[j].last) })
for i := 0; i < len(entries)-target; i++ {
delete(l.failures, entries[i].ip)
}
}

// isBlocked reports whether the IP has exceeded the failure limit.
// It also prunes expired entries and deletes the map entry when empty,
// so read-only checks also contribute to memory hygiene.
Expand All @@ -82,26 +132,11 @@ func (l *loginLimiter) isBlocked(ip string) bool {
return count >= l.limit
}

// clientIP extracts the client IP from the request, honoring
// X-Forwarded-For (set by nginx). Falls back to RemoteAddr.
func clientIP(c *gin.Context) string {
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
// Use the first (leftmost) address — that is the original client.
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
return xff[:i]
}
}
return xff
}
return c.ClientIP()
}

// LoginRateLimit is a gin middleware that blocks IPs with too many recent
// failed login attempts. It must be installed only on the /auth/login route.
func LoginRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
ip := clientIP(c)
ip := c.ClientIP()
if defaultLoginLimiter.isBlocked(ip) {
logging.G(c.Request.Context()).Warnf("login rate limit triggered: client_ip=%s path=%s", ip, c.Request.URL.Path)
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
Expand All @@ -116,5 +151,5 @@ func LoginRateLimit() gin.HandlerFunc {
// markLoginFailure is called by the Login handler when authentication fails.
// It is exported so the handler can trigger it after a failed login.
func markLoginFailure(c *gin.Context) {
defaultLoginLimiter.recordFailure(clientIP(c))
defaultLoginLimiter.recordFailure(c.ClientIP())
}
200 changes: 200 additions & 0 deletions CubeOps/internal/auth/ratelimit_xff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright (c) 2026 Tencent Inc.
// SPDX-License-Identifier: Apache-2.0

package auth

import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/gin-gonic/gin"
)

func newLimiterRouter(t *testing.T, trustedProxies []string) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
if err := r.SetTrustedProxies(trustedProxies); err != nil {
t.Fatalf("SetTrustedProxies: %v", err)
}
r.POST("/login", LoginRateLimit(), func(c *gin.Context) {
markLoginFailure(c)
c.Status(http.StatusUnauthorized)
})
return r
}

func attempt(r *gin.Engine, peer string, xff string) int {
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = peer
if xff != "" {
req.Header.Set("X-Forwarded-For", xff)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w.Code
}

func countBlocked(r *gin.Engine, peer string, xffs []string) int {
blocked := 0
for _, xff := range xffs {
if attempt(r, peer, xff) == http.StatusTooManyRequests {
blocked++
}
}
return blocked
}

func resetLimiter(t *testing.T) {
t.Helper()
defaultLoginLimiter.mu.Lock()
defaultLoginLimiter.failures = map[string][]time.Time{}
defaultLoginLimiter.mu.Unlock()
}

func TestRotatingForwardedForCannotBypassTheLimiter(t *testing.T) {
resetLimiter(t)
r := newLimiterRouter(t, nil)

xffs := make([]string, 0, 40)
for i := 0; i < 40; i++ {
xffs = append(xffs, "10.1.1."+string(rune('0'+i%10))+"9")
}

blocked := countBlocked(r, "203.0.113.9:1234", xffs)
if blocked == 0 {
t.Fatal("rotating X-Forwarded-For bypassed the limiter entirely")
}
if blocked < 30 {
t.Fatalf("only %d/40 attempts were blocked; the peer IP is not being used as the key", blocked)
}
}

func TestWhitespacePaddedForwardedForCannotBypassTheLimiter(t *testing.T) {
resetLimiter(t)
r := newLimiterRouter(t, nil)

xffs := make([]string, 0, 20)
for i := 0; i < 20; i++ {
pad := ""
for j := 0; j < i; j++ {
pad += " "
}
xffs = append(xffs, pad+"198.51.100.7")
}

if blocked := countBlocked(r, "203.0.113.9:1234", xffs); blocked == 0 {
t.Fatal("whitespace-padded X-Forwarded-For bypassed the limiter")
}
}

func TestLimiterStillFiresWithoutForwardedFor(t *testing.T) {
resetLimiter(t)
r := newLimiterRouter(t, nil)

blocked := 0
for i := 0; i < 10; i++ {
if attempt(r, "203.0.113.9:1234", "") == http.StatusTooManyRequests {
blocked++
}
}
if blocked == 0 {
t.Fatal("baseline broken: the limiter never fired")
}
}

func TestDistinctPeerIPsGetIndependentBuckets(t *testing.T) {
resetLimiter(t)
r := newLimiterRouter(t, nil)

for i := 0; i < 6; i++ {
attempt(r, "203.0.113.9:1234", "")
}
if code := attempt(r, "203.0.113.9:1234", ""); code != http.StatusTooManyRequests {
t.Fatalf("first peer should be blocked, got %d", code)
}
if code := attempt(r, "203.0.113.10:1234", ""); code == http.StatusTooManyRequests {
t.Fatal("a different peer IP was blocked by another peer's failures")
}
}

func TestTrustedProxyForwardedForIsHonoured(t *testing.T) {
resetLimiter(t)
r := newLimiterRouter(t, []string{"203.0.113.9"})

for i := 0; i < 6; i++ {
attempt(r, "203.0.113.9:1234", "198.51.100.7")
}
if code := attempt(r, "203.0.113.9:1234", "198.51.100.7"); code != http.StatusTooManyRequests {
t.Fatalf("a client behind a trusted proxy should be blocked, got %d", code)
}
if code := attempt(r, "203.0.113.9:1234", "198.51.100.8"); code == http.StatusTooManyRequests {
t.Fatal("a different client behind the same trusted proxy was blocked")
}
}

func distinctIP(i int) string {
return fmt.Sprintf("10.%d.%d.%d", (i>>16)&0xff, (i>>8)&0xff, i&0xff)
}

func mapSize(l *loginLimiter) int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.failures)
}

func TestExpiredEntriesAreSweptOnceTheThresholdIsReached(t *testing.T) {
l := &loginLimiter{failures: map[string][]time.Time{}, limit: 5, window: 20 * time.Millisecond}
for i := 0; i < sweepThreshold; i++ {
l.recordFailure(distinctIP(i))
}
if got := mapSize(l); got != sweepThreshold {
t.Fatalf("map holds %d entries before the sweep, want %d", got, sweepThreshold)
}

time.Sleep(40 * time.Millisecond)
l.recordFailure(distinctIP(sweepThreshold))

if got := mapSize(l); got != 1 {
t.Fatalf("map holds %d entries after the sweep, want only the fresh one", got)
}
}

func TestLiveEntriesAreEvictedSoTheMapStaysBounded(t *testing.T) {
l := &loginLimiter{failures: map[string][]time.Time{}, limit: 5, window: time.Hour}
const total = sweepThreshold * 3
low := total
for i := 0; i < total; i++ {
l.recordFailure(distinctIP(i))
got := mapSize(l)
if got > sweepThreshold {
t.Fatalf("map grew to %d entries at i=%d, above the threshold %d", got, i, sweepThreshold)
}
if i >= total-sweepThreshold && got < low {
low = got
}
}
if low > evictTarget+1 {
t.Fatalf("map never fell below %d entries, so live entries are never evicted", low)
}
}

func TestEvictionKeepsTheMostRecentAttackers(t *testing.T) {
l := &loginLimiter{failures: map[string][]time.Time{}, limit: 5, window: time.Hour}
for i := 0; i < sweepThreshold-6; i++ {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The eviction path is never exercised here. sweepThreshold is 4096; this fill loop leaves the map at sweepThreshold-6 = 4090 entries, the 5 recent failures bring it to 4095, and the final recordFailure(distinctIP(sweepThreshold)) at line 195 runs with len(l.failures) == 4095 < sweepThreshold — so both sweepLocked and evictOldestLocked are skipped, and the isBlocked(recent) assertion on line 197 checks a map that was never evicted.

As written, this test would pass even if evictOldestLocked were replaced with a delete-everything implementation, so the "most recent attackers are preserved" property it claims to verify is unverified. (TestLiveEntriesAreEvictedSoTheMapStaysBounded verifies the map stays bounded but not the eviction policy.)

To actually hit the branch, the map needs to be at >= sweepThreshold at the entry of a recordFailure call — e.g. fill to sweepThreshold-5, add the recent failures to reach 4096, then record one more distinct IP to trigger sweep/evict, and assert recent is still blocked.

l.recordFailure(distinctIP(i))
}

recent := "203.0.113.7"
for i := 0; i < 5; i++ {
l.recordFailure(recent)
}
l.recordFailure(distinctIP(sweepThreshold))

if !l.isBlocked(recent) {
t.Fatal("the most recently active IP was evicted, so its failures were forgotten")
}
}
Loading
Loading