-
Notifications
You must be signed in to change notification settings - Fork 1k
cubeops: key the login rate limiter on the real peer IP, and bound the failure map #1378
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
c42ac59
32fb3b2
b072f52
92fb351
25c2b61
7e5a7d6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| // Copyright (c) 2026 Tencent Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package auth | ||
|
|
||
| import ( | ||
| "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 TestSweepBoundsTheFailureMap(t *testing.T) { | ||
| l := &loginLimiter{failures: map[string][]time.Time{}, limit: 5, window: time.Millisecond} | ||
| for i := 0; i < sweepThreshold+50; i++ { | ||
| l.recordFailure("10.9." + string(rune('0'+i%10)) + "." + string(rune('0'+(i/10)%10))) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The sweep is never exercised by this test. The generated key only varies two octets ( |
||
| } | ||
| time.Sleep(5 * time.Millisecond) | ||
| l.recordFailure("10.9.9.9") | ||
|
|
||
| l.mu.Lock() | ||
| size := len(l.failures) | ||
| l.mu.Unlock() | ||
| if size > sweepThreshold { | ||
| t.Fatalf("failure map grew to %d entries, above the sweep threshold %d", size, sweepThreshold) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,12 +34,13 @@ import ( | |
| // Config holds all CubeOps runtime configuration. | ||
| type Config struct { | ||
| // Server | ||
| Bind string `yaml:"bind"` | ||
| LogLevel string `yaml:"log_level"` | ||
| LogDir string `yaml:"log_dir"` | ||
| LogFileNum int `yaml:"log_file_num"` | ||
| LogFileSize int `yaml:"log_file_size"` | ||
| JWTSecret string `yaml:"jwt_secret"` | ||
| Bind string `yaml:"bind"` | ||
| LogLevel string `yaml:"log_level"` | ||
| LogDir string `yaml:"log_dir"` | ||
| LogFileNum int `yaml:"log_file_num"` | ||
| LogFileSize int `yaml:"log_file_size"` | ||
| JWTSecret string `yaml:"jwt_secret"` | ||
| TrustedProxies []string `yaml:"trusted_proxies"` | ||
|
|
||
| // Database — either a single URL or the individual fields below. | ||
| DatabaseURL string `yaml:"database_url"` | ||
|
|
@@ -281,6 +282,9 @@ func overrideFromEnv(cfg *Config) { | |
| if v := os.Getenv("JWT_SECRET"); v != "" { | ||
| cfg.JWTSecret = v | ||
| } | ||
| if v := os.Getenv("CUBE_OPS_TRUSTED_PROXIES"); v != "" { | ||
| cfg.TrustedProxies = splitAndTrim(v) | ||
| } | ||
| if v := os.Getenv("DATABASE_URL"); v != "" { | ||
| cfg.DatabaseURL = v | ||
| } | ||
|
|
@@ -325,3 +329,14 @@ func overrideFromEnv(cfg *Config) { | |
| } | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low — this is well-formedness-only validation;
|
||
|
|
||
| func splitAndTrim(v string) []string { | ||
| parts := strings.Split(v, ",") | ||
| out := make([]string, 0, len(parts)) | ||
| for _, p := range parts { | ||
| if p = strings.TrimSpace(p); p != "" { | ||
| out = append(out, p) | ||
| } | ||
| } | ||
| return out | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,6 +81,10 @@ func (s *Server) buildRouter() *gin.Engine { | |
| // to stdout and bypasses any logger the operator has configured. | ||
| gin.SetMode(gin.ReleaseMode) | ||
| r := gin.New() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth an explicit release callout: with The fail-closed default is the right call and correctly stops the XFF bypass, but since this PR already touches the deployment manifests, wiring the actual ingress/CubeProxy CIDR (or at least the All-in-One bridge gateway) for the default path would avoid shipping a known availability regression; at minimum the upgrade/breaking-change notes should state this loudly. |
||
| if err := r.SetTrustedProxies(s.cfg.TrustedProxies); err != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a behavior change for every handler, not just the login limiter: gin's default (used before this PR) trusts all proxies and honors |
||
| logging.G(context.Background()).Errorf("invalid trusted_proxies, falling back to trusting none: err=%q", err.Error()) | ||
| _ = r.SetTrustedProxies(nil) | ||
| } | ||
| r.Use(requestLogger()) | ||
| r.Use(cubeopsRecovery()) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sweepThresholdis 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 subsequentrecordFailurepays a full O(map) sweep while holdingl.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.