CVE-2024-36106 (published 2024-06-06T15:15Z)

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.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/argoproj/argo-cd
commit_details: repo_url: https://github.com/argoproj/argo-cd
author: pasha-codefresh
author_date: 2024-06-06 11:30:10+03:00
msg: Merge pull request from GHSA-3cqf-953p-h5cp

* fix: prevent enumerating by cluster name, return exact error for case when cluster exists and not

Signed-off-by: pashakostohrys <pavel@codefresh.io>

* fix: prevent cluster enumeration by name

Signed-off-by: pashakostohrys <pavel@codefresh.io>

* fix: prevent cluster enumeration by name

Signed-off-by: pashakostohrys <pavel@codefresh.io>

* fix linter and add unit test

Signed-off-by: pashakostohrys <pavel@codefresh.io>

* fix linter and add unit test

Signed-off-by: pashakostohrys <pavel@codefresh.io>

* fix linter and add unit test

Signed-off-by: pashakostohrys <pavel@codefresh.io>

* fix linter and add unit test

Signed-off-by: pashakostohrys <pavel@codefresh.io>

* fix linter and add unit test

Signed-off-by: pashakostohrys <pavel@codefresh.io>

---------

Signed-off-by: pashakostohrys <pavel@codefresh.io>
repository: repo_url: https://github.com/argoproj/argo-cd
repo_name: argoproj/argo-cd
description: Declarative Continuous Deployment for Kubernetes
repo_language: Go
owner: argoproj
file_changes: filename: cluster.go
change_type: ModificationType.MODIFY
diff: @@ -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)

programming_language: Go
filename: cluster_test.go
change_type: ModificationType.MODIFY
diff: @@ -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")
+	})
+}

programming_language: Go