VYPR
Moderate severityNVD Advisory· Published Feb 20, 2026· Updated Feb 24, 2026

Kargo has Missing Authorization Vulnerabilities in Approval & Promotion REST API Endpoints

CVE-2026-27111

Description

Kargo manages and automates the promotion of software artifacts. From v1.9.0 to v1.9.2, Kargo's authorization model includes a promote verb -- a non-standard Kubernetes "dolphin verb" -- that gates the ability to advance Freight through a promotion pipeline. This verb exists to separate the ability to manage promotion-related resources from the ability to trigger promotions, enabling fine-grained access control over what is often a sensitive operation. The promote verb is correctly enforced in Kargo's legacy gRPC API. However, three endpoints in the newer REST API omit this check, relying only on standard Kubernetes RBAC for the underlying resource operations (patch on freights/status or create on promotions). This permits users who hold those standard permissions -- but who were deliberately not granted promote -- to bypass the intended authorization boundary. The affected endpoints are /v1beta1/projects/{project}/freight/{freight}/approve, /v1beta1/projects/{project}/stages/{stage}/promotions, and /v1beta1/projects/{project}/stages/{stage}/promotions/downstream. This vulnerability is fixed in v1.9.3.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/akuity/kargoGo
>= 1.9.0, < 1.9.31.9.3

Affected products

1

Patches

1
833314cad551

Merge commit from fork

https://github.com/akuity/kargoKent RancourtFeb 17, 2026via ghsa
7 files changed · +260 53
  • pkg/server/approve_freight_v1alpha1.go+15 6 modified
    @@ -9,7 +9,6 @@ import (
     
     	"connectrpc.com/connect"
     	"github.com/gin-gonic/gin"
    -	"k8s.io/apimachinery/pkg/runtime/schema"
     	"k8s.io/apimachinery/pkg/types"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     
    @@ -92,11 +91,7 @@ func (s *server) ApproveFreight(
     	if err := s.authorizeFn(
     		ctx,
     		"promote",
    -		schema.GroupVersionResource{
    -			Group:    kargoapi.GroupVersion.Group,
    -			Version:  kargoapi.GroupVersion.Version,
    -			Resource: "stages",
    -		},
    +		kargoapi.GroupVersion.WithResource("stages"),
     		"",
     		types.NamespacedName{
     			Namespace: project,
    @@ -199,6 +194,20 @@ func (s *server) approveFreight(c *gin.Context) {
     		return
     	}
     
    +	if err := s.authorizeFn(
    +		ctx,
    +		"promote",
    +		kargoapi.GroupVersion.WithResource("stages"),
    +		"",
    +		types.NamespacedName{
    +			Namespace: project,
    +			Name:      stageName,
    +		},
    +	); err != nil {
    +		_ = c.Error(err)
    +		return
    +	}
    +
     	if freight.IsApprovedFor(stageName) {
     		c.Status(http.StatusOK)
     		return
    
  • pkg/server/approve_freight_v1alpha1_test.go+36 0 modified
    @@ -10,6 +10,7 @@ import (
     	"connectrpc.com/connect"
     	"github.com/stretchr/testify/require"
     	corev1 "k8s.io/api/core/v1"
    +	apierrors "k8s.io/apimachinery/pkg/api/errors"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     	"k8s.io/apimachinery/pkg/runtime/schema"
     	"k8s.io/apimachinery/pkg/types"
    @@ -441,11 +442,46 @@ func Test_server_approveFreight(t *testing.T) {
     					require.Equal(t, http.StatusNotFound, w.Code)
     				},
     			},
    +			{
    +				name: "not authorized to approve (not authorized to promote)",
    +				clientBuilder: fake.NewClientBuilder().
    +					WithObjects(testProject, testFreight, testStage).
    +					WithStatusSubresource(testFreight),
    +				serverSetup: func(_ *testing.T, s *server) {
    +					s.authorizeFn = func(
    +						context.Context,
    +						string,
    +						schema.GroupVersionResource,
    +						string,
    +						client.ObjectKey,
    +					) error {
    +						return apierrors.NewForbidden(
    +							kargoapi.GroupVersion.WithResource("stages").GroupResource(),
    +							testStageName,
    +							errors.New("not authorized"),
    +						)
    +					}
    +				},
    +				assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) {
    +					require.Equal(t, http.StatusForbidden, w.Code)
    +				},
    +			},
     			{
     				name: "approves Freight",
     				clientBuilder: fake.NewClientBuilder().
     					WithObjects(testProject, testFreight, testStage).
     					WithStatusSubresource(testFreight),
    +				serverSetup: func(_ *testing.T, s *server) {
    +					s.authorizeFn = func(
    +						context.Context,
    +						string,
    +						schema.GroupVersionResource,
    +						string,
    +						client.ObjectKey,
    +					) error {
    +						return nil
    +					}
    +				},
     				assertions: func(t *testing.T, w *httptest.ResponseRecorder, c client.Client) {
     					require.Equal(t, http.StatusOK, w.Code)
     
    
  • pkg/server/promote_downstream_v1alpha1.go+16 0 modified
    @@ -300,6 +300,22 @@ func (s *server) promoteDownstream(c *gin.Context) {
     		return
     	}
     
    +	for _, downstream := range downstreams {
    +		if err := s.authorizeFn(
    +			ctx,
    +			"promote",
    +			kargoapi.GroupVersion.WithResource("stages"),
    +			"",
    +			types.NamespacedName{
    +				Namespace: downstream.Namespace,
    +				Name:      downstream.Name,
    +			},
    +		); err != nil {
    +			_ = c.Error(err)
    +			return
    +		}
    +	}
    +
     	// Validate that freight is available to all downstream stages
     	for _, downstream := range downstreams {
     		if !downstream.IsFreightAvailable(freight) {
    
  • pkg/server/promote_downstream_v1alpha1_test.go+43 1 modified
    @@ -10,6 +10,7 @@ import (
     	"connectrpc.com/connect"
     	"github.com/stretchr/testify/require"
     	corev1 "k8s.io/api/core/v1"
    +	apierrors "k8s.io/apimachinery/pkg/api/errors"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     	"k8s.io/apimachinery/pkg/runtime/schema"
     	"k8s.io/apimachinery/pkg/types"
    @@ -791,13 +792,54 @@ func Test_server_promoteDownstream(t *testing.T) {
     				},
     			},
     			{
    -				name: "Successfully promote downstream",
    +				name: "not authorized to promote to a downstream stage",
     				clientBuilder: fake.NewClientBuilder().WithObjects(
     					testProject,
     					testStage,
     					testDownstreamStage,
     					testFreight,
     				),
    +				serverSetup: func(_ *testing.T, s *server) {
    +					s.authorizeFn = func(
    +						context.Context,
    +						string,
    +						schema.GroupVersionResource,
    +						string,
    +						client.ObjectKey,
    +					) error {
    +						return apierrors.NewForbidden(
    +							kargoapi.GroupVersion.WithResource("stages").GroupResource(),
    +							testDownstreamStage.Name,
    +							errors.New("not authorized"),
    +						)
    +					}
    +				},
    +				body: mustJSONBody(promoteDownstreamRequest{
    +					Freight: testFreight.Name,
    +				}),
    +				assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) {
    +					require.Equal(t, http.StatusForbidden, w.Code)
    +				},
    +			},
    +			{
    +				name: "successfully promotes downstream",
    +				clientBuilder: fake.NewClientBuilder().WithObjects(
    +					testProject,
    +					testStage,
    +					testDownstreamStage,
    +					testFreight,
    +				),
    +				serverSetup: func(_ *testing.T, s *server) {
    +					s.authorizeFn = func(
    +						context.Context,
    +						string,
    +						schema.GroupVersionResource,
    +						string,
    +						client.ObjectKey,
    +					) error {
    +						return nil
    +					}
    +				},
     				body: mustJSONBody(promoteDownstreamRequest{
     					Freight: testFreight.Name,
     				}),
    
  • pkg/server/promote_to_stage_v1alpha1.go+27 18 modified
    @@ -9,7 +9,6 @@ import (
     	"connectrpc.com/connect"
     	"github.com/gin-gonic/gin"
     	apierrors "k8s.io/apimachinery/pkg/api/errors"
    -	"k8s.io/apimachinery/pkg/runtime/schema"
     	"k8s.io/apimachinery/pkg/types"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     
    @@ -94,26 +93,10 @@ func (s *server) PromoteToStage(
     		return nil, connect.NewError(connect.CodeNotFound, err)
     	}
     
    -	if !s.isFreightAvailableFn(stage, freight) {
    -		// nolint:staticcheck
    -		return nil, connect.NewError(
    -			connect.CodeInvalidArgument,
    -			fmt.Errorf(
    -				"Freight %q is not available to Stage %q",
    -				freightName,
    -				stageName,
    -			),
    -		)
    -	}
    -
     	if err = s.authorizeFn(
     		ctx,
     		"promote",
    -		schema.GroupVersionResource{
    -			Group:    kargoapi.GroupVersion.Group,
    -			Version:  kargoapi.GroupVersion.Version,
    -			Resource: "stages",
    -		},
    +		kargoapi.GroupVersion.WithResource("stages"),
     		"",
     		types.NamespacedName{
     			Namespace: project,
    @@ -123,6 +106,18 @@ func (s *server) PromoteToStage(
     		return nil, err
     	}
     
    +	if !s.isFreightAvailableFn(stage, freight) {
    +		// nolint:staticcheck
    +		return nil, connect.NewError(
    +			connect.CodeInvalidArgument,
    +			fmt.Errorf(
    +				"Freight %q is not available to Stage %q",
    +				freightName,
    +				stageName,
    +			),
    +		)
    +	}
    +
     	promotion, err := kargo.NewPromotionBuilder(s.client).Build(ctx, *stage, freight.Name)
     	if err != nil {
     		return nil, fmt.Errorf("build promotion: %w", err)
    @@ -250,6 +245,20 @@ func (s *server) promoteToStage(c *gin.Context) {
     		freight = &list.Items[0]
     	}
     
    +	if err := s.authorizeFn(
    +		ctx,
    +		"promote",
    +		kargoapi.GroupVersion.WithResource("stages"),
    +		"",
    +		types.NamespacedName{
    +			Namespace: project,
    +			Name:      stageName,
    +		},
    +	); err != nil {
    +		_ = c.Error(err)
    +		return
    +	}
    +
     	// Validate that the Freight is available to the Stage
     	if !stage.IsFreightAvailable(freight) {
     		_ = c.Error(libhttp.ErrorStr(
    
  • pkg/server/promote_to_stage_v1alpha1_test.go+115 27 modified
    @@ -10,6 +10,7 @@ import (
     	"connectrpc.com/connect"
     	"github.com/stretchr/testify/require"
     	corev1 "k8s.io/api/core/v1"
    +	apierrors "k8s.io/apimachinery/pkg/api/errors"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     	"k8s.io/apimachinery/pkg/runtime/schema"
     	"k8s.io/apimachinery/pkg/types"
    @@ -229,7 +230,7 @@ func TestPromoteToStage(t *testing.T) {
     			},
     		},
     		{
    -			name: "Freight not available",
    +			name: "promoting not authorized",
     			req: &svcv1alpha1.PromoteToStageRequest{
     				Project: "fake-project",
     				Stage:   "fake-stage",
    @@ -251,12 +252,23 @@ func TestPromoteToStage(t *testing.T) {
     				getFreightByNameOrAliasFn: func(
     					context.Context,
     					client.Client,
    -					string, string, string,
    +					string,
    +					string,
    +					string,
     				) (*kargoapi.Freight, error) {
     					return &kargoapi.Freight{}, nil
     				},
     				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
    -					return false
    +					return true
    +				},
    +				authorizeFn: func(
    +					context.Context,
    +					string,
    +					schema.GroupVersionResource,
    +					string,
    +					client.ObjectKey,
    +				) error {
    +					return errors.New("not authorized")
     				},
     			},
     			assertions: func(
    @@ -265,16 +277,11 @@ func TestPromoteToStage(t *testing.T) {
     				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
     				err error,
     			) {
    -				require.Error(t, err)
    -				var connErr *connect.Error
    -				require.True(t, errors.As(err, &connErr))
    -				require.Equal(t, connect.CodeInvalidArgument, connErr.Code())
    -				require.Contains(t, connErr.Message(), "Freight")
    -				require.Contains(t, connErr.Message(), "is not available to Stage")
    +				require.Error(t, err, "not authorized")
     			},
     		},
     		{
    -			name: "promoting not authorized",
    +			name: "Freight not available",
     			req: &svcv1alpha1.PromoteToStageRequest{
     				Project: "fake-project",
     				Stage:   "fake-stage",
    @@ -296,23 +303,21 @@ func TestPromoteToStage(t *testing.T) {
     				getFreightByNameOrAliasFn: func(
     					context.Context,
     					client.Client,
    -					string,
    -					string,
    -					string,
    +					string, string, string,
     				) (*kargoapi.Freight, error) {
     					return &kargoapi.Freight{}, nil
     				},
    -				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
    -					return true
    -				},
     				authorizeFn: func(
     					context.Context,
     					string,
     					schema.GroupVersionResource,
     					string,
     					client.ObjectKey,
     				) error {
    -					return errors.New("not authorized")
    +					return nil
    +				},
    +				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
    +					return false
     				},
     			},
     			assertions: func(
    @@ -321,7 +326,12 @@ func TestPromoteToStage(t *testing.T) {
     				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
     				err error,
     			) {
    -				require.Error(t, err, "not authorized")
    +				require.Error(t, err)
    +				var connErr *connect.Error
    +				require.True(t, errors.As(err, &connErr))
    +				require.Equal(t, connect.CodeInvalidArgument, connErr.Code())
    +				require.Contains(t, connErr.Message(), "Freight")
    +				require.Contains(t, connErr.Message(), "is not available to Stage")
     			},
     		},
     		{
    @@ -355,9 +365,6 @@ func TestPromoteToStage(t *testing.T) {
     				) (*kargoapi.Freight, error) {
     					return &kargoapi.Freight{}, nil
     				},
    -				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
    -					return true
    -				},
     				authorizeFn: func(
     					context.Context,
     					string,
    @@ -367,6 +374,9 @@ func TestPromoteToStage(t *testing.T) {
     				) error {
     					return nil
     				},
    +				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
    +					return true
    +				},
     			},
     			assertions: func(
     				t *testing.T,
    @@ -413,9 +423,6 @@ func TestPromoteToStage(t *testing.T) {
     						},
     					}, nil
     				},
    -				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
    -					return true
    -				},
     				authorizeFn: func(
     					context.Context,
     					string,
    @@ -425,6 +432,9 @@ func TestPromoteToStage(t *testing.T) {
     				) error {
     					return nil
     				},
    +				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
    +					return true
    +				},
     				createPromotionFn: func(
     					context.Context,
     					client.Object,
    @@ -478,9 +488,6 @@ func TestPromoteToStage(t *testing.T) {
     						},
     					}, nil
     				},
    -				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
    -					return true
    -				},
     				authorizeFn: func(
     					context.Context,
     					string,
    @@ -490,6 +497,9 @@ func TestPromoteToStage(t *testing.T) {
     				) error {
     					return nil
     				},
    +				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
    +					return true
    +				},
     				createPromotionFn: func(
     					context.Context,
     					client.Object,
    @@ -640,9 +650,76 @@ func Test_server_promoteToStage(t *testing.T) {
     					require.Equal(t, http.StatusBadRequest, w.Code)
     				},
     			},
    +			{
    +				name:          "promoting not authorized",
    +				clientBuilder: fake.NewClientBuilder().WithObjects(testProject, testStage, testFreight),
    +				serverSetup: func(_ *testing.T, s *server) {
    +					s.authorizeFn = func(
    +						context.Context,
    +						string,
    +						schema.GroupVersionResource,
    +						string,
    +						client.ObjectKey,
    +					) error {
    +						return apierrors.NewForbidden(
    +							kargoapi.GroupVersion.WithResource("stages").GroupResource(),
    +							testStage.Name,
    +							errors.New("not authorized"),
    +						)
    +					}
    +				},
    +				body: mustJSONBody(promoteToStageRequest{
    +					Freight: testFreight.Name,
    +				}),
    +				assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) {
    +					require.Equal(t, http.StatusForbidden, w.Code)
    +				},
    +			},
    +			{
    +				name: "Freight not available to Stage",
    +				clientBuilder: fake.NewClientBuilder().WithObjects(
    +					testProject,
    +					func() *kargoapi.Stage {
    +						s := testStage.DeepCopy()
    +						s.Spec.RequestedFreight[0].Sources = kargoapi.FreightSources{
    +							Stages: []string{"some-other-stage"},
    +						}
    +						return s
    +					}(),
    +					testFreight,
    +				),
    +				serverSetup: func(_ *testing.T, s *server) {
    +					s.authorizeFn = func(
    +						context.Context,
    +						string,
    +						schema.GroupVersionResource,
    +						string,
    +						client.ObjectKey,
    +					) error {
    +						return nil
    +					}
    +				},
    +				body: mustJSONBody(promoteToStageRequest{
    +					Freight: testFreight.Name,
    +				}),
    +				assertions: func(t *testing.T, w *httptest.ResponseRecorder, _ client.Client) {
    +					require.Equal(t, http.StatusBadRequest, w.Code)
    +				},
    +			},
     			{
     				name:          "Successfully promote by freight name",
     				clientBuilder: fake.NewClientBuilder().WithObjects(testProject, testStage, testFreight),
    +				serverSetup: func(_ *testing.T, s *server) {
    +					s.authorizeFn = func(
    +						context.Context,
    +						string,
    +						schema.GroupVersionResource,
    +						string,
    +						client.ObjectKey,
    +					) error {
    +						return nil
    +					}
    +				},
     				body: mustJSONBody(promoteToStageRequest{
     					Freight: testFreight.Name,
     				}),
    @@ -661,6 +738,17 @@ func Test_server_promoteToStage(t *testing.T) {
     			{
     				name:          "Successfully promote by freight alias",
     				clientBuilder: fake.NewClientBuilder().WithObjects(testProject, testStage, testFreight),
    +				serverSetup: func(_ *testing.T, s *server) {
    +					s.authorizeFn = func(
    +						context.Context,
    +						string,
    +						schema.GroupVersionResource,
    +						string,
    +						client.ObjectKey,
    +					) error {
    +						return nil
    +					}
    +				},
     				body: mustJSONBody(promoteToStageRequest{
     					FreightAlias: "fake-alias",
     				}),
    
  • pkg/server/rest_test.go+8 1 modified
    @@ -41,7 +41,10 @@ type restTestCase struct {
     	headers       map[string]string
     	clientBuilder *fake.ClientBuilder
     	serverConfig  *config.ServerConfig
    -	assertions    func(*testing.T, *httptest.ResponseRecorder, client.Client)
    +	// serverSetup is an optional function that can be used to perform additional
    +	// case-specific server initialization.
    +	serverSetup func(*testing.T, *server)
    +	assertions  func(*testing.T, *httptest.ResponseRecorder, client.Client)
     }
     
     func testRESTEndpoint(
    @@ -108,6 +111,10 @@ func testRESTEndpoint(
     				rbac.RolesDatabaseConfig{KargoNamespace: testKargoNamespace},
     			)
     
    +			if testCase.serverSetup != nil {
    +				testCase.serverSetup(t, s)
    +			}
    +
     			u := url
     			if testCase.url != "" {
     				u = testCase.url
    

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

News mentions

0

No linked articles in our index yet.