Moderate severityNVD Advisory· Published Jun 6, 2024· Updated Sep 3, 2024
Argo CD allows authenticated users to enumerate clusters by name
CVE-2024-36106
Description
Argo CD is a declarative, GitOps continuous delivery tool for Kubernetes. It’s possible for authenticated users to enumerate clusters by name by inspecting error messages. It’s also possible to enumerate the names of projects with project-scoped clusters if you know the names of the clusters. This vulnerability is fixed in 2.11.3, 2.10.12, and 2.9.17.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/argoproj/argo-cdGo | >= 0.11.0, < 2.9.17 | 2.9.17 |
github.com/argoproj/argo-cdGo | >= 2.10.0, < 2.10.12 | 2.10.12 |
github.com/argoproj/argo-cdGo | >= 2.11.0, < 2.11.3 | 2.11.3 |
Affected products
1Patches
1c2647055c261Merge pull request from GHSA-3cqf-953p-h5cp
2 files changed · +192 −21
server/cluster/cluster.go+30 −21 modified@@ -187,15 +187,11 @@ func (s *Server) Create(ctx context.Context, q *cluster.ClusterCreateRequest) (* // Get returns a cluster from a query func (s *Server) Get(ctx context.Context, q *cluster.ClusterQuery) (*appv1.Cluster, error) { - c, err := s.getClusterWith403IfNotExist(ctx, q) + c, err := s.getClusterAndVerifyAccess(ctx, q, rbacpolicy.ActionGet) if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceClusters, rbacpolicy.ActionGet, CreateClusterRBACObject(c.Project, q.Server)); err != nil { - return nil, err - } - return s.toAPIResponse(c), nil } @@ -207,6 +203,21 @@ func (s *Server) getClusterWith403IfNotExist(ctx context.Context, q *cluster.Clu return repo, nil } +func (s *Server) getClusterAndVerifyAccess(ctx context.Context, q *cluster.ClusterQuery, action string) (*appv1.Cluster, error) { + c, err := s.getClusterWith403IfNotExist(ctx, q) + if err != nil { + return nil, err + } + + // verify that user can do the specified action inside project where cluster is located + if !s.enf.Enforce(ctx.Value("claims"), rbacpolicy.ResourceClusters, action, CreateClusterRBACObject(c.Project, c.Server)) { + log.WithField("cluster", q.Server).Warnf("encountered permissions issue while processing request: %v", err) + return nil, common.PermissionDeniedAPIError + } + + return c, nil +} + func (s *Server) getCluster(ctx context.Context, q *cluster.ClusterQuery) (*appv1.Cluster, error) { if q.Id != nil { q.Server = "" @@ -278,20 +289,16 @@ var clusterFieldsByPath = map[string]func(updated *appv1.Cluster, existing *appv // Update updates a cluster func (s *Server) Update(ctx context.Context, q *cluster.ClusterUpdateRequest) (*appv1.Cluster, error) { - c, err := s.getClusterWith403IfNotExist(ctx, &cluster.ClusterQuery{ + c, err := s.getClusterAndVerifyAccess(ctx, &cluster.ClusterQuery{ Server: q.Cluster.Server, Name: q.Cluster.Name, Id: q.Id, - }) + }, rbacpolicy.ActionUpdate) + if err != nil { return nil, err } - // verify that user can do update inside project where cluster is located - if !s.enf.Enforce(ctx.Value("claims"), rbacpolicy.ResourceClusters, rbacpolicy.ActionUpdate, CreateClusterRBACObject(c.Project, c.Server)) { - return nil, common.PermissionDeniedAPIError - } - if len(q.UpdatedFields) == 0 || sets.NewString(q.UpdatedFields...).Has("project") { // verify that user can do update inside project where cluster will be located if !s.enf.Enforce(ctx.Value("claims"), rbacpolicy.ResourceClusters, rbacpolicy.ActionUpdate, CreateClusterRBACObject(q.Cluster.Project, c.Server)) { @@ -341,7 +348,8 @@ func (s *Server) Delete(ctx context.Context, q *cluster.ClusterQuery) (*cluster. if q.Name != "" { servers, err := s.db.GetClusterServersByName(ctx, q.Name) if err != nil { - return nil, err + log.WithField("cluster", q.Name).Warnf("failed to get cluster servers by name: %v", err) + return nil, common.PermissionDeniedAPIError } for _, server := range servers { if err := enforceAndDelete(s, ctx, server, c.Project); err != nil { @@ -359,7 +367,8 @@ func (s *Server) Delete(ctx context.Context, q *cluster.ClusterQuery) (*cluster. func enforceAndDelete(s *Server, ctx context.Context, server, project string) error { if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceClusters, rbacpolicy.ActionDelete, CreateClusterRBACObject(project, server)); err != nil { - return err + log.WithField("cluster", server).Warnf("encountered permissions issue while processing request: %v", err) + return common.PermissionDeniedAPIError } if err := s.db.DeleteCluster(ctx, server); err != nil { return err @@ -378,16 +387,19 @@ func (s *Server) RotateAuth(ctx context.Context, q *cluster.ClusterQuery) (*clus if q.Name != "" { servers, err = s.db.GetClusterServersByName(ctx, q.Name) if err != nil { - return nil, status.Errorf(codes.NotFound, "failed to get cluster servers by name: %v", err) + log.WithField("cluster", q.Name).Warnf("failed to get cluster servers by name: %v", err) + return nil, common.PermissionDeniedAPIError } for _, server := range servers { if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceClusters, rbacpolicy.ActionUpdate, CreateClusterRBACObject(clust.Project, server)); err != nil { - return nil, status.Errorf(codes.PermissionDenied, "encountered permissions issue while processing request: %v", err) + log.WithField("cluster", server).Warnf("encountered permissions issue while processing request: %v", err) + return nil, common.PermissionDeniedAPIError } } } else { if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceClusters, rbacpolicy.ActionUpdate, CreateClusterRBACObject(clust.Project, q.Server)); err != nil { - return nil, status.Errorf(codes.PermissionDenied, "encountered permissions issue while processing request: %v", err) + log.WithField("cluster", q.Server).Warnf("encountered permissions issue while processing request: %v", err) + return nil, common.PermissionDeniedAPIError } servers = append(servers, q.Server) } @@ -467,13 +479,10 @@ func (s *Server) toAPIResponse(clust *appv1.Cluster) *appv1.Cluster { // InvalidateCache invalidates cluster cache func (s *Server) InvalidateCache(ctx context.Context, q *cluster.ClusterQuery) (*appv1.Cluster, error) { - cls, err := s.getClusterWith403IfNotExist(ctx, q) + cls, err := s.getClusterAndVerifyAccess(ctx, q, rbacpolicy.ActionUpdate) if err != nil { return nil, err } - if err := s.enf.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceClusters, rbacpolicy.ActionUpdate, CreateClusterRBACObject(cls.Project, q.Server)); err != nil { - return nil, err - } now := v1.Now() cls.RefreshRequestedAt = &now cls, err = s.db.UpdateCluster(ctx, cls)
server/cluster/cluster_test.go+162 −0 modified@@ -4,6 +4,9 @@ import ( "context" "encoding/json" "fmt" + "github.com/argoproj/argo-cd/v2/server/rbacpolicy" + "github.com/argoproj/argo-cd/v2/util/assets" + "github.com/golang-jwt/jwt/v4" "reflect" "testing" "time" @@ -51,6 +54,16 @@ func newNoopEnforcer() *rbac.Enforcer { return enf } +func newEnforcer() *rbac.Enforcer { + enforcer := rbac.NewEnforcer(fake.NewSimpleClientset(test.NewFakeConfigMap()), test.FakeArgoCDNamespace, common.ArgoCDRBACConfigMapName, nil) + _ = enforcer.SetBuiltinPolicy(assets.BuiltinPolicyCSV) + enforcer.SetDefaultRole("role:test") + enforcer.SetClaimsEnforcerFunc(func(claims jwt.Claims, rvals ...interface{}) bool { + return true + }) + return enforcer +} + func TestUpdateCluster_RejectInvalidParams(t *testing.T) { testCases := []struct { name string @@ -604,3 +617,152 @@ func TestListCluster(t *testing.T) { }) } } + +func TestGetClusterAndVerifyAccess(t *testing.T) { + t.Run("GetClusterAndVerifyAccess - No Cluster", func(t *testing.T) { + db := &dbmocks.ArgoDB{} + + mockCluster := v1alpha1.Cluster{ + Name: "test/ing", + Server: "https://127.0.0.1", + Namespaces: []string{"default", "kube-system"}, + } + mockClusterList := v1alpha1.ClusterList{ + ListMeta: v1.ListMeta{}, + Items: []v1alpha1.Cluster{ + mockCluster, + }, + } + + db.On("ListClusters", mock.Anything).Return(&mockClusterList, nil) + + server := NewServer(db, newNoopEnforcer(), newServerInMemoryCache(), &kubetest.MockKubectlCmd{}) + cluster, err := server.getClusterAndVerifyAccess(context.Background(), &clusterapi.ClusterQuery{ + Name: "test/not-exists", + }, rbacpolicy.ActionGet) + + assert.Nil(t, cluster) + assert.ErrorIs(t, err, common.PermissionDeniedAPIError) + }) + + t.Run("GetClusterAndVerifyAccess - Permissions Denied", func(t *testing.T) { + db := &dbmocks.ArgoDB{} + + mockCluster := v1alpha1.Cluster{ + Name: "test/ing", + Server: "https://127.0.0.1", + Namespaces: []string{"default", "kube-system"}, + } + mockClusterList := v1alpha1.ClusterList{ + ListMeta: v1.ListMeta{}, + Items: []v1alpha1.Cluster{ + mockCluster, + }, + } + + db.On("ListClusters", mock.Anything).Return(&mockClusterList, nil) + + server := NewServer(db, newEnforcer(), newServerInMemoryCache(), &kubetest.MockKubectlCmd{}) + cluster, err := server.getClusterAndVerifyAccess(context.Background(), &clusterapi.ClusterQuery{ + Name: "test/ing", + }, rbacpolicy.ActionGet) + + assert.Nil(t, cluster) + assert.ErrorIs(t, err, common.PermissionDeniedAPIError) + }) +} + +func TestNoClusterEnumeration(t *testing.T) { + db := &dbmocks.ArgoDB{} + + mockCluster := v1alpha1.Cluster{ + Name: "test/ing", + Server: "https://127.0.0.1", + Namespaces: []string{"default", "kube-system"}, + } + mockClusterList := v1alpha1.ClusterList{ + ListMeta: v1.ListMeta{}, + Items: []v1alpha1.Cluster{ + mockCluster, + }, + } + + db.On("ListClusters", mock.Anything).Return(&mockClusterList, nil) + db.On("GetCluster", mock.Anything, mock.Anything).Return(&mockCluster, nil) + + server := NewServer(db, newEnforcer(), newServerInMemoryCache(), &kubetest.MockKubectlCmd{}) + + t.Run("Get", func(t *testing.T) { + _, err := server.Get(context.Background(), &clusterapi.ClusterQuery{ + Name: "cluster-not-exists", + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + + _, err = server.Get(context.Background(), &clusterapi.ClusterQuery{ + Name: "test/ing", + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + }) + + t.Run("Update", func(t *testing.T) { + _, err := server.Update(context.Background(), &clusterapi.ClusterUpdateRequest{ + Cluster: &v1alpha1.Cluster{ + Name: "cluster-not-exists", + }, + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + + _, err = server.Update(context.Background(), &clusterapi.ClusterUpdateRequest{ + Cluster: &v1alpha1.Cluster{ + Name: "test/ing", + }, + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + }) + + t.Run("Delete", func(t *testing.T) { + _, err := server.Delete(context.Background(), &clusterapi.ClusterQuery{ + Server: "https://127.0.0.2", + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + + _, err = server.Delete(context.Background(), &clusterapi.ClusterQuery{ + Server: "https://127.0.0.1", + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + }) + + t.Run("RotateAuth", func(t *testing.T) { + _, err := server.RotateAuth(context.Background(), &clusterapi.ClusterQuery{ + Server: "https://127.0.0.2", + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + + _, err = server.RotateAuth(context.Background(), &clusterapi.ClusterQuery{ + Server: "https://127.0.0.1", + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + }) + + t.Run("InvalidateCache", func(t *testing.T) { + _, err := server.InvalidateCache(context.Background(), &clusterapi.ClusterQuery{ + Server: "https://127.0.0.2", + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + + _, err = server.InvalidateCache(context.Background(), &clusterapi.ClusterQuery{ + Server: "https://127.0.0.1", + }) + assert.Error(t, err) + assert.Equal(t, common.PermissionDeniedAPIError.Error(), err.Error(), "error message must be _only_ the permission error, to avoid leaking information about cluster existence") + }) +}
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
4- github.com/advisories/GHSA-3cqf-953p-h5cpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-36106ghsaADVISORY
- github.com/argoproj/argo-cd/commit/c2647055c261a550e5da075793260f6524e65ad9ghsax_refsource_MISCWEB
- github.com/argoproj/argo-cd/security/advisories/GHSA-3cqf-953p-h5cpghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.