Argo CD vulnerable to Bypassing of Brute Force Protection via Application Crash and In-Memory Data Loss
Description
Argo CD is a declarative, GitOps continuous delivery tool for Kubernetes. Prior to versions 2.8.13, 2.9.9, and 2.10.4, an attacker can exploit a chain of vulnerabilities, including a Denial of Service (DoS) flaw and in-memory data storage weakness, to effectively bypass the application's brute force login protection. This is a critical security vulnerability that allows attackers to bypass the brute force login protection mechanism. Not only can they crash the service affecting all users, but they can also make unlimited login attempts, increasing the risk of account compromise. Versions 2.8.13, 2.9.9, and 2.10.4 contain a patch for this issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/argoproj/argo-cd/v2Go | < 2.8.13 | 2.8.13 |
github.com/argoproj/argo-cd/v2Go | >= 2.9.0, < 2.9.9 | 2.9.9 |
github.com/argoproj/argo-cd/v2Go | >= 2.10.0, < 2.10.4 | 2.10.4 |
Affected products
1Patches
36e181d72b315Merge pull request from GHSA-2vgg-9h6w-m454
2 files changed · +60 −15
util/session/sessionmanager.go+21 −15 modified@@ -69,7 +69,7 @@ const ( // Maximum length of username, too keep the cache's memory signature low maxUsernameLength = 32 // The default maximum session cache size - defaultMaxCacheSize = 1000 + defaultMaxCacheSize = 10000 // The default number of maximum login failures before delay kicks in defaultMaxLoginFailures = 5 // The default time in seconds for the failure window @@ -310,6 +310,22 @@ func expireOldFailedAttempts(maxAge time.Duration, failures *map[string]LoginAtt return expiredCount } +// Protect admin user from login attempt reset caused by attempts to overflow cache in a brute force attack. Instead remove random non-admin to make room in cache. +func pickRandomNonAdminLoginFailure(failures map[string]LoginAttempts, username string) *string { + idx := rand.Intn(len(failures) - 1) + i := 0 + for key := range failures { + if i == idx { + if key == common.ArgoCDAdminUsername || key == username { + return pickRandomNonAdminLoginFailure(failures, username) + } + return &key + } + i++ + } + return nil +} + // Updates the failure count for a given username. If failed is true, increases the counter. Otherwise, sets counter back to 0. func (mgr *SessionManager) updateFailureCount(username string, failed bool) { @@ -327,23 +343,13 @@ func (mgr *SessionManager) updateFailureCount(username string, failed bool) { // prevent overbloating the cache with fake entries, as this could lead to // memory exhaustion and ultimately in a DoS. We remove a single entry to // replace it with the new one. - // - // Chances are that we remove the one that is under active attack, but this - // chance is low (1:cache_size) if failed && len(failures) >= getMaximumCacheSize() { log.Warnf("Session cache size exceeds %d entries, removing random entry", getMaximumCacheSize()) - idx := rand.Intn(len(failures) - 1) - var rmUser string - i := 0 - for key := range failures { - if i == idx { - rmUser = key - delete(failures, key) - break - } - i++ + rmUser := pickRandomNonAdminLoginFailure(failures, username) + if rmUser != nil { + delete(failures, *rmUser) + log.Infof("Deleted entry for user %s from cache", *rmUser) } - log.Infof("Deleted entry for user %s from cache", rmUser) } attempt, ok := failures[username]
util/session/sessionmanager_test.go+39 −0 modified@@ -1173,3 +1173,42 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL), assert.ErrorIs(t, err, common.TokenVerificationErr) }) } + +func Test_PickFailureAttemptWhenOverflowed(t *testing.T) { + t.Run("Not pick admin user from the queue", func(t *testing.T) { + failures := map[string]LoginAttempts{ + "admin": { + FailCount: 1, + }, + "test2": { + FailCount: 1, + }, + } + + // inside pickRandomNonAdminLoginFailure, it uses random, so we need to test it multiple times + for i := 0; i < 1000; i++ { + user := pickRandomNonAdminLoginFailure(failures, "test") + assert.Equal(t, "test2", *user) + } + }) + + t.Run("Not pick admin user and current user from the queue", func(t *testing.T) { + failures := map[string]LoginAttempts{ + "test": { + FailCount: 1, + }, + "admin": { + FailCount: 1, + }, + "test2": { + FailCount: 1, + }, + } + + // inside pickRandomNonAdminLoginFailure, it uses random, so we need to test it multiple times + for i := 0; i < 1000; i++ { + user := pickRandomNonAdminLoginFailure(failures, "test") + assert.Equal(t, "test2", *user) + } + }) +}
cebb6538f794Merge pull request from GHSA-2vgg-9h6w-m454
2 files changed · +60 −15
util/session/sessionmanager.go+21 −15 modified@@ -69,7 +69,7 @@ const ( // Maximum length of username, too keep the cache's memory signature low maxUsernameLength = 32 // The default maximum session cache size - defaultMaxCacheSize = 1000 + defaultMaxCacheSize = 10000 // The default number of maximum login failures before delay kicks in defaultMaxLoginFailures = 5 // The default time in seconds for the failure window @@ -310,6 +310,22 @@ func expireOldFailedAttempts(maxAge time.Duration, failures *map[string]LoginAtt return expiredCount } +// Protect admin user from login attempt reset caused by attempts to overflow cache in a brute force attack. Instead remove random non-admin to make room in cache. +func pickRandomNonAdminLoginFailure(failures map[string]LoginAttempts, username string) *string { + idx := rand.Intn(len(failures) - 1) + i := 0 + for key := range failures { + if i == idx { + if key == common.ArgoCDAdminUsername || key == username { + return pickRandomNonAdminLoginFailure(failures, username) + } + return &key + } + i++ + } + return nil +} + // Updates the failure count for a given username. If failed is true, increases the counter. Otherwise, sets counter back to 0. func (mgr *SessionManager) updateFailureCount(username string, failed bool) { @@ -327,23 +343,13 @@ func (mgr *SessionManager) updateFailureCount(username string, failed bool) { // prevent overbloating the cache with fake entries, as this could lead to // memory exhaustion and ultimately in a DoS. We remove a single entry to // replace it with the new one. - // - // Chances are that we remove the one that is under active attack, but this - // chance is low (1:cache_size) if failed && len(failures) >= getMaximumCacheSize() { log.Warnf("Session cache size exceeds %d entries, removing random entry", getMaximumCacheSize()) - idx := rand.Intn(len(failures) - 1) - var rmUser string - i := 0 - for key := range failures { - if i == idx { - rmUser = key - delete(failures, key) - break - } - i++ + rmUser := pickRandomNonAdminLoginFailure(failures, username) + if rmUser != nil { + delete(failures, *rmUser) + log.Infof("Deleted entry for user %s from cache", *rmUser) } - log.Infof("Deleted entry for user %s from cache", rmUser) } attempt, ok := failures[username]
util/session/sessionmanager_test.go+39 −0 modified@@ -1173,3 +1173,42 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL), assert.ErrorIs(t, err, common.TokenVerificationErr) }) } + +func Test_PickFailureAttemptWhenOverflowed(t *testing.T) { + t.Run("Not pick admin user from the queue", func(t *testing.T) { + failures := map[string]LoginAttempts{ + "admin": { + FailCount: 1, + }, + "test2": { + FailCount: 1, + }, + } + + // inside pickRandomNonAdminLoginFailure, it uses random, so we need to test it multiple times + for i := 0; i < 1000; i++ { + user := pickRandomNonAdminLoginFailure(failures, "test") + assert.Equal(t, "test2", *user) + } + }) + + t.Run("Not pick admin user and current user from the queue", func(t *testing.T) { + failures := map[string]LoginAttempts{ + "test": { + FailCount: 1, + }, + "admin": { + FailCount: 1, + }, + "test2": { + FailCount: 1, + }, + } + + // inside pickRandomNonAdminLoginFailure, it uses random, so we need to test it multiple times + for i := 0; i < 1000; i++ { + user := pickRandomNonAdminLoginFailure(failures, "test") + assert.Equal(t, "test2", *user) + } + }) +}
17b0df1168a4Merge pull request from GHSA-2vgg-9h6w-m454
2 files changed · +60 −15
util/session/sessionmanager.go+21 −15 modified@@ -69,7 +69,7 @@ const ( // Maximum length of username, too keep the cache's memory signature low maxUsernameLength = 32 // The default maximum session cache size - defaultMaxCacheSize = 1000 + defaultMaxCacheSize = 10000 // The default number of maximum login failures before delay kicks in defaultMaxLoginFailures = 5 // The default time in seconds for the failure window @@ -310,6 +310,22 @@ func expireOldFailedAttempts(maxAge time.Duration, failures *map[string]LoginAtt return expiredCount } +// Protect admin user from login attempt reset caused by attempts to overflow cache in a brute force attack. Instead remove random non-admin to make room in cache. +func pickRandomNonAdminLoginFailure(failures map[string]LoginAttempts, username string) *string { + idx := rand.Intn(len(failures) - 1) + i := 0 + for key := range failures { + if i == idx { + if key == common.ArgoCDAdminUsername || key == username { + return pickRandomNonAdminLoginFailure(failures, username) + } + return &key + } + i++ + } + return nil +} + // Updates the failure count for a given username. If failed is true, increases the counter. Otherwise, sets counter back to 0. func (mgr *SessionManager) updateFailureCount(username string, failed bool) { @@ -327,23 +343,13 @@ func (mgr *SessionManager) updateFailureCount(username string, failed bool) { // prevent overbloating the cache with fake entries, as this could lead to // memory exhaustion and ultimately in a DoS. We remove a single entry to // replace it with the new one. - // - // Chances are that we remove the one that is under active attack, but this - // chance is low (1:cache_size) if failed && len(failures) >= getMaximumCacheSize() { log.Warnf("Session cache size exceeds %d entries, removing random entry", getMaximumCacheSize()) - idx := rand.Intn(len(failures) - 1) - var rmUser string - i := 0 - for key := range failures { - if i == idx { - rmUser = key - delete(failures, key) - break - } - i++ + rmUser := pickRandomNonAdminLoginFailure(failures, username) + if rmUser != nil { + delete(failures, *rmUser) + log.Infof("Deleted entry for user %s from cache", *rmUser) } - log.Infof("Deleted entry for user %s from cache", rmUser) } attempt, ok := failures[username]
util/session/sessionmanager_test.go+39 −0 modified@@ -1188,3 +1188,42 @@ requestedScopes: ["oidc"]`, oidcTestServer.URL), assert.ErrorIs(t, err, common.TokenVerificationErr) }) } + +func Test_PickFailureAttemptWhenOverflowed(t *testing.T) { + t.Run("Not pick admin user from the queue", func(t *testing.T) { + failures := map[string]LoginAttempts{ + "admin": { + FailCount: 1, + }, + "test2": { + FailCount: 1, + }, + } + + // inside pickRandomNonAdminLoginFailure, it uses random, so we need to test it multiple times + for i := 0; i < 1000; i++ { + user := pickRandomNonAdminLoginFailure(failures, "test") + assert.Equal(t, "test2", *user) + } + }) + + t.Run("Not pick admin user and current user from the queue", func(t *testing.T) { + failures := map[string]LoginAttempts{ + "test": { + FailCount: 1, + }, + "admin": { + FailCount: 1, + }, + "test2": { + FailCount: 1, + }, + } + + // inside pickRandomNonAdminLoginFailure, it uses random, so we need to test it multiple times + for i := 0; i < 1000; i++ { + user := pickRandomNonAdminLoginFailure(failures, "test") + assert.Equal(t, "test2", *user) + } + }) +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-x32m-mvfj-52xvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-21652ghsaADVISORY
- argo-cd.readthedocs.io/en/stable/security_considerations/ghsaWEB
- github.com/argoproj/argo-cd/commit/17b0df1168a4c535f6f37e95f25ed7cd81e1fa4dghsaWEB
- github.com/argoproj/argo-cd/commit/6e181d72b31522f886a2afa029d5b26d7912ec7bghsaWEB
- github.com/argoproj/argo-cd/commit/cebb6538f7944c87ca2fecb5d17f8baacc431456ghsaWEB
- github.com/argoproj/argo-cd/security/advisories/GHSA-x32m-mvfj-52xvghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.