Kargo's `GetConfig()` and `RefreshResource()` API endpoints allow unauthenticated access
Description
Kargo manages and automates the promotion of software artifacts. Prior to versions 1.8.7, 1.7.7, and 1.6.3, a bug was found with authentication checks on the GetConfig() API endpoint. This allowed unauthenticated users to access this endpoint by specifying an Authorization header with any non-empty Bearer token value, regardless of validity. This vulnerability did allow for exfiltration of configuration data such as endpoints for connected Argo CD clusters. This data could allow an attacker to enumerate cluster URLs and namespaces for use in subsequent attacks. Additionally, the same bug affected the RefreshResource endpoint. This endpoint does not lead to any information disclosure, but could be used by an unauthenticated attacker to perform a denial-of-service style attack against the Kargo API. RefreshResource sets an annotation on specific Kubernetes resources to trigger reconciliations. If run on a constant loop, this could also slow down legitimate requests to the Kubernetes API server. This problem has been patched in Kargo versiosn 1.8.7, 1.7.7, and 1.6.3. There are no workarounds for this issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/akuity/kargoGo | < 1.6.3 | 1.6.3 |
github.com/akuity/kargoGo | >= 1.7.0-rc.1, < 1.7.7 | 1.7.7 |
github.com/akuity/kargoGo | >= 1.8.0-rc.1, < 1.8.7 | 1.8.7 |
Affected products
1Patches
35 files changed · +146 −43
cmd/controlplane/api.go+1 −0 modified@@ -78,6 +78,7 @@ func (o *apiOptions) run(ctx context.Context) error { return fmt.Errorf("error getting Kubernetes client REST config: %w", err) } kubernetes.ConfigureQPSBurst(ctx, restCfg, o.QPS, o.Burst) + serverCfg.RestConfig = restCfg kubeClientOptions := kubernetes.ClientOptions{} if serverCfg.OIDCConfig != nil {
internal/cli/cmd/server/server.go+2 −1 modified@@ -91,7 +91,8 @@ func (o *serverOptions) run(ctx context.Context) error { srv := server.NewServer( apiconfig.ServerConfig{ - LocalMode: true, + RestConfig: restCfg, + LocalMode: true, }, client, rbac.NewKubernetesRolesDatabase(client),
internal/server/config/config.go+2 −0 modified@@ -6,6 +6,7 @@ import ( "time" "github.com/kelseyhightower/envconfig" + "k8s.io/client-go/rest" "github.com/akuity/kargo/internal/os" "github.com/akuity/kargo/internal/server/dex" @@ -32,6 +33,7 @@ type ServerConfig struct { AnalysisRunLogToken string AnalysisRunLogHTTPHeaders map[string]string ClusterSecretNamespace string + RestConfig *rest.Config } func ServerConfigFromEnv() ServerConfig {
internal/server/option/auth.go+63 −31 modified@@ -20,6 +20,7 @@ import ( "github.com/hashicorp/go-cleanhttp" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" libClient "sigs.k8s.io/controller-runtime/pkg/client" kargoapi "github.com/akuity/kargo/api/v1alpha1" @@ -50,16 +51,11 @@ type authInterceptor struct { claims jwt.Claims, ) (*jwt.Token, []string, error) verifyKargoIssuedTokenFn func(rawToken string) bool - verifyIDPIssuedTokenFn func( - ctx context.Context, - rawToken string, - ) (claims, error) - oidcTokenVerifyFn goOIDCIDTokenVerifyFn - oidcExtractClaimsFn func(*oidc.IDToken) (claims, error) - listServiceAccountsFn func( - ctx context.Context, - c claims, - ) (map[string]map[types.NamespacedName]struct{}, error) + verifyIDPIssuedTokenFn func(ctx context.Context, rawToken string) (claims, error) + verifyKubernetesTokenFn func(ctx context.Context, rawToken string) error + oidcTokenVerifyFn goOIDCIDTokenVerifyFn + oidcExtractClaimsFn func(*oidc.IDToken) (claims, error) + listServiceAccountsFn func(ctx context.Context, c claims) (map[string]map[types.NamespacedName]struct{}, error) } // goOIDCIDTokenVerifyFn is a github.com/coreos/go-oidc/v3/oidc/IDTokenVerifier.Verify() function @@ -82,6 +78,7 @@ func newAuthInterceptor( jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified a.verifyKargoIssuedTokenFn = a.verifyKargoIssuedToken a.verifyIDPIssuedTokenFn = a.verifyIDPIssuedToken + a.verifyKubernetesTokenFn = a.verifyKubernetesToken a.oidcExtractClaimsFn = oidcExtractClaims a.listServiceAccountsFn = a.listServiceAccounts return a @@ -370,22 +367,17 @@ func (a *authInterceptor) authenticate( // Are we dealing with a JWT? // - // Note: If this is a JWT, we cannot trust these claims yet because we're not - // verifying the token yet. We use untrustedClaims.Issuer only as a hint as to - // HOW we might be able to verify the token further. + // If not, we no longer assume this is potentially some other form of token + // that the Kubernetes API server might recognize, as that is an increasingly + // unlikely scenario. + // + // If this IS a JWT, we cannot trust these claims yet because we're not + // verifying the token just yet. We use untrustedClaims.Issuer only as a hint + // as to HOW we might be able to verify the token further. untrustedClaims := jwt.RegisteredClaims{} if _, _, err := a.parseUnverifiedJWTFn(rawToken, &untrustedClaims); err != nil { - // This token isn't a JWT, so it's probably an opaque bearer token for the - // Kubernetes API server. Just run with it. If we're wrong, Kubernetes API - // calls will simply have auth errors that will bubble back to the client. - return user.ContextWithInfo( - ctx, - user.Info{ - BearerToken: rawToken, - }, - ), nil + return ctx, errors.New("invalid token") } - logger.Debug("found untrusted claims in token", "claims", untrustedClaims) // If we get to here, we're dealing with a JWT. It could have been issued: @@ -453,15 +445,16 @@ func (a *authInterceptor) authenticate( } - // Case 3 or 4: We don't know how to verify this token. It's probably a token - // issued by the Kubernetes cluster's identity provider. Just run with it. If - // we're wrong, Kubernetes API calls will simply have auth errors that will - // bubble back to the client. + // Case 3 or 4: We don't know how to verify this token. It's possibly a token + // issued by the Kubernetes cluster's identity provider. - logger.Debug( - "could not verify token; assuming it might have been issued by " + - "Kubernetes cluster identity provider", - ) + // Test whether Kubernetes recognizes this token by making a request to /api + logger.Debug("could not verify token; checking if Kubernetes recognizes it") + if err := a.verifyKubernetesTokenFn(ctx, rawToken); err != nil { + logger.Debug("token not recognized by Kubernetes", "error", err) + return ctx, errors.New("invalid token") + } + logger.Debug("token recognized by Kubernetes") return user.ContextWithInfo( ctx, @@ -531,3 +524,42 @@ func oidcExtractClaims(token *oidc.IDToken) (claims, error) { err := token.Claims(&c) return c, err } + +// verifyKubernetesToken tests whether the Kubernetes API server recognizes the +// provided token by making a GET request to the /api endpoint. This is a +// lightweight check that doesn't require any specific permissions. +func (a *authInterceptor) verifyKubernetesToken( + ctx context.Context, + rawToken string, +) error { + if a.cfg.RestConfig == nil { // This shouldn't happen, but just in case... + return errors.New("Kubernetes REST config is not available") // nolint: staticcheck + } + + transport, err := rest.TransportFor(a.cfg.RestConfig) + if err != nil { + return fmt.Errorf("create transport: %w", err) + } + + apiURL := strings.TrimSuffix(a.cfg.RestConfig.Host, "/") + "/api" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+rawToken) + + resp, err := (&http.Client{Transport: transport}).Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf( + "unexpected response from Kubernetes API server: %d", + resp.StatusCode, + ) + } + + return nil +}
internal/server/option/auth_test.go+78 −11 modified@@ -14,6 +14,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" "github.com/akuity/kargo/internal/server/config" "github.com/akuity/kargo/internal/server/dex" @@ -52,6 +53,7 @@ func TestNewAuthInterceptor(t *testing.T) { require.NotNil(t, a.parseUnverifiedJWTFn) require.NotNil(t, a.verifyKargoIssuedTokenFn) require.NotNil(t, a.verifyIDPIssuedTokenFn) + require.NotNil(t, a.verifyKubernetesTokenFn) require.NotNil(t, a.oidcExtractClaimsFn) require.NotNil(t, a.listServiceAccountsFn) } @@ -199,14 +201,10 @@ func TestAuthenticate(t *testing.T) { }, }, token: testToken, - // We can't parse the token as a JWT, so we assume it could be an opaque - // bearer token for the k8s API server. We expect user info containing the - // raw token to be bound to the context. assertions: func(ctx context.Context, err error) { - require.NoError(t, err) - u, ok := user.InfoFromContext(ctx) - require.True(t, ok) - require.Equal(t, testToken, u.BearerToken) + require.Equal(t, "invalid token", err.Error()) + _, ok := user.InfoFromContext(ctx) + require.False(t, ok) }, }, "failure verifying Kargo-issued token": { @@ -354,7 +352,7 @@ func TestAuthenticate(t *testing.T) { require.Equal(t, testToken, u.BearerToken) }, }, - "unrecognized JWT": { + "unrecognized JWT recognized by Kubernetes": { procedure: testProcedure, authInterceptor: &authInterceptor{ parseUnverifiedJWTFn: func(_ string, claims jwt.Claims) (*jwt.Token, []string, error) { @@ -363,18 +361,44 @@ func TestAuthenticate(t *testing.T) { rc.Issuer = "unrecognized-issuer" return nil, nil, nil }, + verifyKubernetesTokenFn: func(context.Context, string) error { + return nil // Token is recognized by Kubernetes + }, }, token: testToken, - // We can't verify this token, so we assume it could be an an identity - // token from the k8s API server's identity provider. We expect user info - // containing the raw token to be bound to the context. + // We can't verify this token, so we check if Kubernetes recognizes it. + // In this case it does, so we expect user info containing the raw token + // to be bound to the context. assertions: func(ctx context.Context, err error) { require.NoError(t, err) u, ok := user.InfoFromContext(ctx) require.True(t, ok) require.Equal(t, testToken, u.BearerToken) }, }, + "unrecognized JWT not recognized by Kubernetes": { + procedure: testProcedure, + authInterceptor: &authInterceptor{ + parseUnverifiedJWTFn: func(_ string, claims jwt.Claims) (*jwt.Token, []string, error) { + rc, ok := claims.(*jwt.RegisteredClaims) + require.True(t, ok) + rc.Issuer = "unrecognized-issuer" + return nil, nil, nil + }, + verifyKubernetesTokenFn: func(context.Context, string) error { + return errors.New("token not recognized") + }, + }, + token: testToken, + // We can't verify this token and Kubernetes doesn't recognize it either. + // This should result in an authentication error. + assertions: func(ctx context.Context, err error) { + require.Error(t, err) + require.Equal(t, "invalid token", err.Error()) + _, ok := user.InfoFromContext(ctx) + require.False(t, ok) + }, + }, } for name, ts := range testSets { t.Run(name, func(t *testing.T) { @@ -594,3 +618,46 @@ func TestVerifyKargoIssuedToken(t *testing.T) { }) } } + +func TestVerifyKubernetesToken(t *testing.T) { + const testToken = "test-bearer-token" + + testCases := []struct { + name string + mockK8sAPIHandler http.HandlerFunc + assertions func(t *testing.T, err error) + }{ + { + name: "Kubernetes API returns 200", + mockK8sAPIHandler: func(w http.ResponseWriter, r *http.Request) { + // Verify the token was passed correctly + require.Equal(t, "Bearer "+testToken, r.Header.Get("Authorization")) + require.Equal(t, "/api", r.URL.Path) + w.WriteHeader(http.StatusOK) + }, + assertions: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + { + name: "Kubernetes API returns non-200", + mockK8sAPIHandler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + assertions: func(t *testing.T, err error) { + require.ErrorContains(t, err, "unexpected response from Kubernetes API server: 401") + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + srv := httptest.NewServer(testCase.mockK8sAPIHandler) + t.Cleanup(srv.Close) + authenticator := &authInterceptor{ + cfg: config.ServerConfig{RestConfig: &rest.Config{Host: srv.URL}}, + } + err := authenticator.verifyKubernetesToken(t.Context(), testToken) + testCase.assertions(t, err) + }) + } +}
5 files changed · +145 −38
cmd/controlplane/api.go+1 −0 modified@@ -78,6 +78,7 @@ func (o *apiOptions) run(ctx context.Context) error { return fmt.Errorf("error getting Kubernetes client REST config: %w", err) } kubernetes.ConfigureQPSBurst(ctx, restCfg, o.QPS, o.Burst) + serverCfg.RestConfig = restCfg kubeClientOptions := kubernetes.ClientOptions{} if serverCfg.OIDCConfig != nil {
internal/cli/cmd/server/server.go+2 −1 modified@@ -91,7 +91,8 @@ func (o *serverOptions) run(ctx context.Context) error { srv := server.NewServer( apiconfig.ServerConfig{ - LocalMode: true, + RestConfig: restCfg, + LocalMode: true, }, client, rbac.NewKubernetesRolesDatabase(client),
internal/server/config/config.go+2 −0 modified@@ -6,6 +6,7 @@ import ( "time" "github.com/kelseyhightower/envconfig" + "k8s.io/client-go/rest" "github.com/akuity/kargo/internal/os" "github.com/akuity/kargo/internal/server/dex" @@ -32,6 +33,7 @@ type ServerConfig struct { AnalysisRunLogToken string AnalysisRunLogHTTPHeaders map[string]string ClusterSecretNamespace string + RestConfig *rest.Config } func ServerConfigFromEnv() ServerConfig {
internal/server/option/auth.go+62 −26 modified@@ -19,6 +19,7 @@ import ( "github.com/hashicorp/go-cleanhttp" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" libClient "sigs.k8s.io/controller-runtime/pkg/client" kargoapi "github.com/akuity/kargo/api/v1alpha1" @@ -49,16 +50,11 @@ type authInterceptor struct { claims jwt.Claims, ) (*jwt.Token, []string, error) verifyKargoIssuedTokenFn func(rawToken string) bool - verifyIDPIssuedTokenFn func( - ctx context.Context, - rawToken string, - ) (claims, error) - oidcTokenVerifyFn goOIDCIDTokenVerifyFn - oidcExtractClaimsFn func(*oidc.IDToken) (claims, error) - listServiceAccountsFn func( - ctx context.Context, - c claims, - ) (map[string]map[types.NamespacedName]struct{}, error) + verifyIDPIssuedTokenFn func(ctx context.Context, rawToken string) (claims, error) + verifyKubernetesTokenFn func(ctx context.Context, rawToken string) error + oidcTokenVerifyFn goOIDCIDTokenVerifyFn + oidcExtractClaimsFn func(*oidc.IDToken) (claims, error) + listServiceAccountsFn func(ctx context.Context, c claims) (map[string]map[types.NamespacedName]struct{}, error) } // goOIDCIDTokenVerifyFn is a github.com/coreos/go-oidc/v3/oidc/IDTokenVerifier.Verify() function @@ -81,6 +77,7 @@ func newAuthInterceptor( jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified a.verifyKargoIssuedTokenFn = a.verifyKargoIssuedToken a.verifyIDPIssuedTokenFn = a.verifyIDPIssuedToken + a.verifyKubernetesTokenFn = a.verifyKubernetesToken a.oidcExtractClaimsFn = oidcExtractClaims a.listServiceAccountsFn = a.listServiceAccounts return a @@ -348,20 +345,16 @@ func (a *authInterceptor) authenticate( // Are we dealing with a JWT? // - // Note: If this is a JWT, we cannot trust these claims yet because we're not - // verifying the token yet. We use untrustedClaims.Issuer only as a hint as to - // HOW we might be able to verify the token further. + // If not, we no longer assume this is potentially some other form of token + // that the Kubernetes API server might recognize, as that is an increasingly + // unlikely scenario. + // + // If this IS a JWT, we cannot trust these claims yet because we're not + // verifying the token just yet. We use untrustedClaims.Issuer only as a hint + // as to HOW we might be able to verify the token further. untrustedClaims := jwt.RegisteredClaims{} if _, _, err := a.parseUnverifiedJWTFn(rawToken, &untrustedClaims); err != nil { - // This token isn't a JWT, so it's probably an opaque bearer token for the - // Kubernetes API server. Just run with it. If we're wrong, Kubernetes API - // calls will simply have auth errors that will bubble back to the client. - return user.ContextWithInfo( - ctx, - user.Info{ - BearerToken: rawToken, - }, - ), nil + return ctx, errors.New("invalid token") } // If we get to here, we're dealing with a JWT. It could have been issued: @@ -420,10 +413,14 @@ func (a *authInterceptor) authenticate( } - // Case 3 or 4: We don't know how to verify this token. It's probably a token - // issued by the Kubernetes cluster's identity provider. Just run with it. If - // we're wrong, Kubernetes API calls will simply have auth errors that will - // bubble back to the client. + // Case 3 or 4: We don't know how to verify this token. It's possibly a token + // issued by the Kubernetes cluster's identity provider. + + // Test whether Kubernetes recognizes this token by making a request to /api + if err := a.verifyKubernetesTokenFn(ctx, rawToken); err != nil { + return ctx, errors.New("invalid token") + } + return user.ContextWithInfo( ctx, user.Info{ @@ -492,3 +489,42 @@ func oidcExtractClaims(token *oidc.IDToken) (claims, error) { err := token.Claims(&c) return c, err } + +// verifyKubernetesToken tests whether the Kubernetes API server recognizes the +// provided token by making a GET request to the /api endpoint. This is a +// lightweight check that doesn't require any specific permissions. +func (a *authInterceptor) verifyKubernetesToken( + ctx context.Context, + rawToken string, +) error { + if a.cfg.RestConfig == nil { // This shouldn't happen, but just in case... + return errors.New("Kubernetes REST config is not available") // nolint: staticcheck + } + + transport, err := rest.TransportFor(a.cfg.RestConfig) + if err != nil { + return fmt.Errorf("create transport: %w", err) + } + + apiURL := strings.TrimSuffix(a.cfg.RestConfig.Host, "/") + "/api" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+rawToken) + + resp, err := (&http.Client{Transport: transport}).Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf( + "unexpected response from Kubernetes API server: %d", + resp.StatusCode, + ) + } + + return nil +}
internal/server/option/auth_test.go+78 −11 modified@@ -14,6 +14,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" "github.com/akuity/kargo/internal/server/config" "github.com/akuity/kargo/internal/server/dex" @@ -52,6 +53,7 @@ func TestNewAuthInterceptor(t *testing.T) { require.NotNil(t, a.parseUnverifiedJWTFn) require.NotNil(t, a.verifyKargoIssuedTokenFn) require.NotNil(t, a.verifyIDPIssuedTokenFn) + require.NotNil(t, a.verifyKubernetesTokenFn) require.NotNil(t, a.oidcExtractClaimsFn) require.NotNil(t, a.listServiceAccountsFn) } @@ -199,14 +201,10 @@ func TestAuthenticate(t *testing.T) { }, }, token: testToken, - // We can't parse the token as a JWT, so we assume it could be an opaque - // bearer token for the k8s API server. We expect user info containing the - // raw token to be bound to the context. assertions: func(ctx context.Context, err error) { - require.NoError(t, err) - u, ok := user.InfoFromContext(ctx) - require.True(t, ok) - require.Equal(t, testToken, u.BearerToken) + require.Equal(t, "invalid token", err.Error()) + _, ok := user.InfoFromContext(ctx) + require.False(t, ok) }, }, "failure verifying Kargo-issued token": { @@ -354,7 +352,7 @@ func TestAuthenticate(t *testing.T) { require.Equal(t, testToken, u.BearerToken) }, }, - "unrecognized JWT": { + "unrecognized JWT recognized by Kubernetes": { procedure: testProcedure, authInterceptor: &authInterceptor{ parseUnverifiedJWTFn: func(_ string, claims jwt.Claims) (*jwt.Token, []string, error) { @@ -363,18 +361,44 @@ func TestAuthenticate(t *testing.T) { rc.Issuer = "unrecognized-issuer" return nil, nil, nil }, + verifyKubernetesTokenFn: func(context.Context, string) error { + return nil // Token is recognized by Kubernetes + }, }, token: testToken, - // We can't verify this token, so we assume it could be an an identity - // token from the k8s API server's identity provider. We expect user info - // containing the raw token to be bound to the context. + // We can't verify this token, so we check if Kubernetes recognizes it. + // In this case it does, so we expect user info containing the raw token + // to be bound to the context. assertions: func(ctx context.Context, err error) { require.NoError(t, err) u, ok := user.InfoFromContext(ctx) require.True(t, ok) require.Equal(t, testToken, u.BearerToken) }, }, + "unrecognized JWT not recognized by Kubernetes": { + procedure: testProcedure, + authInterceptor: &authInterceptor{ + parseUnverifiedJWTFn: func(_ string, claims jwt.Claims) (*jwt.Token, []string, error) { + rc, ok := claims.(*jwt.RegisteredClaims) + require.True(t, ok) + rc.Issuer = "unrecognized-issuer" + return nil, nil, nil + }, + verifyKubernetesTokenFn: func(context.Context, string) error { + return errors.New("token not recognized") + }, + }, + token: testToken, + // We can't verify this token and Kubernetes doesn't recognize it either. + // This should result in an authentication error. + assertions: func(ctx context.Context, err error) { + require.Error(t, err) + require.Equal(t, "invalid token", err.Error()) + _, ok := user.InfoFromContext(ctx) + require.False(t, ok) + }, + }, } for name, ts := range testSets { t.Run(name, func(t *testing.T) { @@ -594,3 +618,46 @@ func TestVerifyKargoIssuedToken(t *testing.T) { }) } } + +func TestVerifyKubernetesToken(t *testing.T) { + const testToken = "test-bearer-token" + + testCases := []struct { + name string + mockK8sAPIHandler http.HandlerFunc + assertions func(t *testing.T, err error) + }{ + { + name: "Kubernetes API returns 200", + mockK8sAPIHandler: func(w http.ResponseWriter, r *http.Request) { + // Verify the token was passed correctly + require.Equal(t, "Bearer "+testToken, r.Header.Get("Authorization")) + require.Equal(t, "/api", r.URL.Path) + w.WriteHeader(http.StatusOK) + }, + assertions: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + { + name: "Kubernetes API returns non-200", + mockK8sAPIHandler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + assertions: func(t *testing.T, err error) { + require.ErrorContains(t, err, "unexpected response from Kubernetes API server: 401") + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + srv := httptest.NewServer(testCase.mockK8sAPIHandler) + t.Cleanup(srv.Close) + authenticator := &authInterceptor{ + cfg: config.ServerConfig{RestConfig: &rest.Config{Host: srv.URL}}, + } + err := authenticator.verifyKubernetesToken(t.Context(), testToken) + testCase.assertions(t, err) + }) + } +}
5 files changed · +146 −43
cmd/controlplane/api.go+1 −0 modified@@ -83,6 +83,7 @@ func (o *apiOptions) run(ctx context.Context) error { return fmt.Errorf("error getting Kubernetes client REST config: %w", err) } kubernetes.ConfigureQPSBurst(ctx, restCfg, o.QPS, o.Burst) + serverCfg.RestConfig = restCfg kubeClientOptions := kubernetes.ClientOptions{} if serverCfg.OIDCConfig != nil {
pkg/cli/cmd/server/server.go+2 −1 modified@@ -92,7 +92,8 @@ func (o *serverOptions) run(ctx context.Context) error { srv := server.NewServer( apiconfig.ServerConfig{ - LocalMode: true, + RestConfig: restCfg, + LocalMode: true, }, client, rbac.NewKubernetesRolesDatabase(client),
pkg/server/config/config.go+2 −0 modified@@ -6,6 +6,7 @@ import ( "time" "github.com/kelseyhightower/envconfig" + "k8s.io/client-go/rest" "github.com/akuity/kargo/pkg/os" "github.com/akuity/kargo/pkg/server/dex" @@ -32,6 +33,7 @@ type ServerConfig struct { AnalysisRunLogToken string AnalysisRunLogHTTPHeaders map[string]string ClusterSecretNamespace string + RestConfig *rest.Config } func ServerConfigFromEnv() ServerConfig {
pkg/server/option/auth.go+63 −31 modified@@ -20,6 +20,7 @@ import ( "github.com/hashicorp/go-cleanhttp" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" libClient "sigs.k8s.io/controller-runtime/pkg/client" kargoapi "github.com/akuity/kargo/api/v1alpha1" @@ -50,16 +51,11 @@ type authInterceptor struct { claims jwt.Claims, ) (*jwt.Token, []string, error) verifyKargoIssuedTokenFn func(rawToken string) bool - verifyIDPIssuedTokenFn func( - ctx context.Context, - rawToken string, - ) (claims, error) - oidcTokenVerifyFn goOIDCIDTokenVerifyFn - oidcExtractClaimsFn func(*oidc.IDToken) (claims, error) - listServiceAccountsFn func( - ctx context.Context, - c claims, - ) (map[string]map[types.NamespacedName]struct{}, error) + verifyIDPIssuedTokenFn func(ctx context.Context, rawToken string) (claims, error) + verifyKubernetesTokenFn func(ctx context.Context, rawToken string) error + oidcTokenVerifyFn goOIDCIDTokenVerifyFn + oidcExtractClaimsFn func(*oidc.IDToken) (claims, error) + listServiceAccountsFn func(ctx context.Context, c claims) (map[string]map[types.NamespacedName]struct{}, error) } // goOIDCIDTokenVerifyFn is a github.com/coreos/go-oidc/v3/oidc/IDTokenVerifier.Verify() function @@ -81,6 +77,7 @@ func newAuthInterceptor( a.parseUnverifiedJWTFn = jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified a.verifyKargoIssuedTokenFn = a.verifyKargoIssuedToken a.verifyIDPIssuedTokenFn = a.verifyIDPIssuedToken + a.verifyKubernetesTokenFn = a.verifyKubernetesToken a.oidcExtractClaimsFn = oidcExtractClaims a.listServiceAccountsFn = a.listServiceAccounts return a @@ -368,22 +365,17 @@ func (a *authInterceptor) authenticate( // Are we dealing with a JWT? // - // Note: If this is a JWT, we cannot trust these claims yet because we're not - // verifying the token yet. We use untrustedClaims.Issuer only as a hint as to - // HOW we might be able to verify the token further. + // If not, we no longer assume this is potentially some other form of token + // that the Kubernetes API server might recognize, as that is an increasingly + // unlikely scenario. + // + // If this IS a JWT, we cannot trust these claims yet because we're not + // verifying the token just yet. We use untrustedClaims.Issuer only as a hint + // as to HOW we might be able to verify the token further. untrustedClaims := jwt.RegisteredClaims{} if _, _, err := a.parseUnverifiedJWTFn(rawToken, &untrustedClaims); err != nil { - // This token isn't a JWT, so it's probably an opaque bearer token for the - // Kubernetes API server. Just run with it. If we're wrong, Kubernetes API - // calls will simply have auth errors that will bubble back to the client. - return user.ContextWithInfo( - ctx, - user.Info{ - BearerToken: rawToken, - }, - ), nil + return ctx, errors.New("invalid token") } - logger.Debug("found untrusted claims in token", "claims", untrustedClaims) // If we get to here, we're dealing with a JWT. It could have been issued: @@ -451,15 +443,16 @@ func (a *authInterceptor) authenticate( } - // Case 3 or 4: We don't know how to verify this token. It's probably a token - // issued by the Kubernetes cluster's identity provider. Just run with it. If - // we're wrong, Kubernetes API calls will simply have auth errors that will - // bubble back to the client. + // Case 3 or 4: We don't know how to verify this token. It's possibly a token + // issued by the Kubernetes cluster's identity provider. - logger.Debug( - "could not verify token; assuming it might have been issued by " + - "Kubernetes cluster identity provider", - ) + // Test whether Kubernetes recognizes this token by making a request to /api + logger.Debug("could not verify token; checking if Kubernetes recognizes it") + if err := a.verifyKubernetesTokenFn(ctx, rawToken); err != nil { + logger.Debug("token not recognized by Kubernetes", "error", err) + return ctx, errors.New("invalid token") + } + logger.Debug("token recognized by Kubernetes") return user.ContextWithInfo( ctx, @@ -529,3 +522,42 @@ func oidcExtractClaims(token *oidc.IDToken) (claims, error) { err := token.Claims(&c) return c, err } + +// verifyKubernetesToken tests whether the Kubernetes API server recognizes the +// provided token by making a GET request to the /api endpoint. This is a +// lightweight check that doesn't require any specific permissions. +func (a *authInterceptor) verifyKubernetesToken( + ctx context.Context, + rawToken string, +) error { + if a.cfg.RestConfig == nil { // This shouldn't happen, but just in case... + return errors.New("Kubernetes REST config is not available") // nolint: staticcheck + } + + transport, err := rest.TransportFor(a.cfg.RestConfig) + if err != nil { + return fmt.Errorf("create transport: %w", err) + } + + apiURL := strings.TrimSuffix(a.cfg.RestConfig.Host, "/") + "/api" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+rawToken) + + resp, err := (&http.Client{Transport: transport}).Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf( + "unexpected response from Kubernetes API server: %d", + resp.StatusCode, + ) + } + + return nil +}
pkg/server/option/auth_test.go+78 −11 modified@@ -14,6 +14,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" "github.com/akuity/kargo/pkg/server/config" "github.com/akuity/kargo/pkg/server/dex" @@ -52,6 +53,7 @@ func TestNewAuthInterceptor(t *testing.T) { require.NotNil(t, a.parseUnverifiedJWTFn) require.NotNil(t, a.verifyKargoIssuedTokenFn) require.NotNil(t, a.verifyIDPIssuedTokenFn) + require.NotNil(t, a.verifyKubernetesTokenFn) require.NotNil(t, a.oidcExtractClaimsFn) require.NotNil(t, a.listServiceAccountsFn) } @@ -199,14 +201,10 @@ func TestAuthenticate(t *testing.T) { }, }, token: testToken, - // We can't parse the token as a JWT, so we assume it could be an opaque - // bearer token for the k8s API server. We expect user info containing the - // raw token to be bound to the context. assertions: func(ctx context.Context, err error) { - require.NoError(t, err) - u, ok := user.InfoFromContext(ctx) - require.True(t, ok) - require.Equal(t, testToken, u.BearerToken) + require.Equal(t, "invalid token", err.Error()) + _, ok := user.InfoFromContext(ctx) + require.False(t, ok) }, }, "failure verifying Kargo-issued token": { @@ -354,7 +352,7 @@ func TestAuthenticate(t *testing.T) { require.Equal(t, testToken, u.BearerToken) }, }, - "unrecognized JWT": { + "unrecognized JWT recognized by Kubernetes": { procedure: testProcedure, authInterceptor: &authInterceptor{ parseUnverifiedJWTFn: func(_ string, claims jwt.Claims) (*jwt.Token, []string, error) { @@ -363,18 +361,44 @@ func TestAuthenticate(t *testing.T) { rc.Issuer = "unrecognized-issuer" return nil, nil, nil }, + verifyKubernetesTokenFn: func(context.Context, string) error { + return nil // Token is recognized by Kubernetes + }, }, token: testToken, - // We can't verify this token, so we assume it could be an an identity - // token from the k8s API server's identity provider. We expect user info - // containing the raw token to be bound to the context. + // We can't verify this token, so we check if Kubernetes recognizes it. + // In this case it does, so we expect user info containing the raw token + // to be bound to the context. assertions: func(ctx context.Context, err error) { require.NoError(t, err) u, ok := user.InfoFromContext(ctx) require.True(t, ok) require.Equal(t, testToken, u.BearerToken) }, }, + "unrecognized JWT not recognized by Kubernetes": { + procedure: testProcedure, + authInterceptor: &authInterceptor{ + parseUnverifiedJWTFn: func(_ string, claims jwt.Claims) (*jwt.Token, []string, error) { + rc, ok := claims.(*jwt.RegisteredClaims) + require.True(t, ok) + rc.Issuer = "unrecognized-issuer" + return nil, nil, nil + }, + verifyKubernetesTokenFn: func(context.Context, string) error { + return errors.New("token not recognized") + }, + }, + token: testToken, + // We can't verify this token and Kubernetes doesn't recognize it either. + // This should result in an authentication error. + assertions: func(ctx context.Context, err error) { + require.Error(t, err) + require.Equal(t, "invalid token", err.Error()) + _, ok := user.InfoFromContext(ctx) + require.False(t, ok) + }, + }, } for name, ts := range testSets { t.Run(name, func(t *testing.T) { @@ -594,3 +618,46 @@ func TestVerifyKargoIssuedToken(t *testing.T) { }) } } + +func TestVerifyKubernetesToken(t *testing.T) { + const testToken = "test-bearer-token" + + testCases := []struct { + name string + mockK8sAPIHandler http.HandlerFunc + assertions func(t *testing.T, err error) + }{ + { + name: "Kubernetes API returns 200", + mockK8sAPIHandler: func(w http.ResponseWriter, r *http.Request) { + // Verify the token was passed correctly + require.Equal(t, "Bearer "+testToken, r.Header.Get("Authorization")) + require.Equal(t, "/api", r.URL.Path) + w.WriteHeader(http.StatusOK) + }, + assertions: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + { + name: "Kubernetes API returns non-200", + mockK8sAPIHandler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + assertions: func(t *testing.T, err error) { + require.ErrorContains(t, err, "unexpected response from Kubernetes API server: 401") + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + srv := httptest.NewServer(testCase.mockK8sAPIHandler) + t.Cleanup(srv.Close) + authenticator := &authInterceptor{ + cfg: config.ServerConfig{RestConfig: &rest.Config{Host: srv.URL}}, + } + err := authenticator.verifyKubernetesToken(t.Context(), testToken) + testCase.assertions(t, err) + }) + } +}
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
6- github.com/advisories/GHSA-w5wv-wvrp-v5m5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-24748ghsaADVISORY
- github.com/akuity/kargo/commit/23646eaefb449a6cc2e76a8033e8a57f71369772ghsax_refsource_MISCWEB
- github.com/akuity/kargo/commit/aa28f81ac15ad871c6eba329fc2f0417a08c39d7ghsax_refsource_MISCWEB
- github.com/akuity/kargo/commit/b3297ace0d3b9e7f7128858c5c4288d77f072b8cghsax_refsource_MISCWEB
- github.com/akuity/kargo/security/advisories/GHSA-w5wv-wvrp-v5m5ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.