Kargo has Missing Authorization Vulnerabilities in Approval & Promotion REST API Endpoints
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.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/akuity/kargoGo | >= 1.9.0, < 1.9.3 | 1.9.3 |
Affected products
1Patches
17 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- github.com/advisories/GHSA-5vvm-67pj-72g4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27111ghsaADVISORY
- github.com/akuity/kargo/commit/833314cad5513d48d89431493325ae44c1324a49ghsax_refsource_MISCWEB
- github.com/akuity/kargo/security/advisories/GHSA-5vvm-67pj-72g4ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.