VYPR
Medium severity4.3GHSA Advisory· Published Apr 2, 2025· Updated Apr 15, 2026

CVE-2025-2786

CVE-2025-2786

Description

Tempo Operator misconfiguration allows namespace users to extract a ServiceAccount token and probe user permissions via TokenReview and SubjectAccessReview, leading to information disclosure.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Tempo Operator misconfiguration allows namespace users to extract a ServiceAccount token and probe user permissions via TokenReview and SubjectAccessReview, leading to information disclosure.

The vulnerability lies in the Tempo Operator for Kubernetes, which creates a ServiceAccount, ClusterRole, and ClusterRoleBinding when a user deploys a TempoStack or TempoMonolithic instance. This design allows any user with full access to their namespace to extract the ServiceAccount token and use it to submit TokenReview and SubjectAccessReview requests, potentially revealing information about other users' permissions [1][2].

Exploitation requires a user to have full administrative access to their namespace, which is a common scenario in multi-tenant clusters. The attacker can then extract the token from the ServiceAccount and make API calls to the Kubernetes API server to check permissions of other users. This does not allow privilege escalation or impersonation, but it provides reconnaissance capabilities [1].

The impact is information disclosure – an attacker can learn about the permissions of other users in the cluster, which could aid in planning further attacks. The vulnerability has a CVSS v3 base score of 4.3 (Medium) [1][3].

The issue has been fixed in the Tempo Operator by ensuring that the operator does not grant additional permissions when enabling OpenShift tenancy mode. The fix is included in Red Hat security updates RHSA-2025:3607 and RHSA-2025:3740 [2][4]. Users are advised to update to the patched versions.

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/grafana/tempo-operatorGo
< 0.16.00.16.0

Affected products

2

Patches

1
0f3f6ad9dec4

Fix CVE-2025-2786: Ensure the operator does not grant additional permissions when enabling OpenShift tenancy mode (#1145)

https://github.com/grafana/tempo-operatorAndreas GerstmayrApr 15, 2025via ghsa
6 files changed · +147 15
  • .chloggen/ensure_permissions.yaml+24 0 added
    @@ -0,0 +1,24 @@
    +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
    +change_type: breaking
    +
    +# The name of the component, or a single word describing the area of concern, (e.g. tempostack, tempomonolithic, github action)
    +component: tempostack, tempomonolithic
    +
    +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
    +note: Ensure the operator does not grant additional permissions when enabling OpenShift tenancy mode (resolves CVE-2025-2786)
    +
    +# One or more tracking issues related to the change
    +issues: [1145]
    +
    +# (Optional) One or more lines of additional information to render under the primary note.
    +# These lines will be padded with 2 spaces and then inserted directly into the document.
    +# Use pipe (|) for multiline entries.
    +subtext: |
    +  Ensure the permissions the operator is granting to the Tempo Service Account
    +  do not exceed the permissions of the user creating (or modifying) the Tempo instance
    +  when enabling OpenShift tenancy mode.
    +
    +  To enable the OpenShift tenancy mode, the user must have permissions to create `TokenReview` and `SubjectAccessReview`.
    +
    +  This breaking change does not affect existing Tempo instances in the cluster.
    +  However, the required permissions are now mandatory when creating or modifying a TempoStack or TempoMonolithic CR.
    
  • internal/webhooks/tempomonolithic_webhook.go+13 2 modified
    @@ -92,7 +92,7 @@ func (v *monolithicValidator) validateTempoMonolithic(ctx context.Context, tempo
     	errors = append(errors, validateName(tempo.Name)...)
     	addValidationResults(v.validateStorage(ctx, tempo))
     	errors = append(errors, v.validateJaegerUI(tempo)...)
    -	errors = append(errors, v.validateMultitenancy(tempo)...)
    +	errors = append(errors, v.validateMultitenancy(ctx, tempo)...)
     	errors = append(errors, v.validateObservability(tempo)...)
     	errors = append(errors, v.validateServiceAccount(ctx, tempo)...)
     	errors = append(errors, v.validateConflictWithTempoStack(ctx, tempo)...)
    @@ -151,7 +151,7 @@ func (v *monolithicValidator) validateJaegerUI(tempo tempov1alpha1.TempoMonolith
     	return nil
     }
     
    -func (v *monolithicValidator) validateMultitenancy(tempo tempov1alpha1.TempoMonolithic) field.ErrorList {
    +func (v *monolithicValidator) validateMultitenancy(ctx context.Context, tempo tempov1alpha1.TempoMonolithic) field.ErrorList {
     	if tempo.Spec.Query != nil && tempo.Spec.Query.RBAC.Enabled && (tempo.Spec.Multitenancy == nil || !tempo.Spec.Multitenancy.Enabled) {
     		return field.ErrorList{
     			field.Invalid(field.NewPath("spec", "rbac", "enabled"), tempo.Spec.Query.RBAC.Enabled,
    @@ -165,6 +165,17 @@ func (v *monolithicValidator) validateMultitenancy(tempo tempov1alpha1.TempoMono
     
     	multitenancyBase := field.NewPath("spec", "multitenancy")
     
    +	if tempo.Spec.Multitenancy != nil && tempo.Spec.Multitenancy.Mode == v1alpha1.ModeOpenShift {
    +		err := validateGatewayOpenShiftModeRBAC(ctx, v.client)
    +		if err != nil {
    +			return field.ErrorList{field.Invalid(
    +				multitenancyBase.Child("mode"),
    +				tempo.Spec.Multitenancy.Mode,
    +				fmt.Sprintf("Cannot enable OpenShift tenancy mode: %v", err),
    +			)}
    +		}
    +	}
    +
     	err := ValidateTenantConfigs(&tempo.Spec.Multitenancy.TenantsSpec, tempo.Spec.Multitenancy.IsGatewayEnabled())
     	if err != nil {
     		return field.ErrorList{field.Invalid(multitenancyBase.Child("enabled"), tempo.Spec.Multitenancy.Enabled, err.Error())}
    
  • internal/webhooks/tempomonolithic_webhook_test.go+14 5 modified
    @@ -4,20 +4,23 @@ import (
     	"context"
     	"testing"
     
    +	configv1alpha1 "github.com/grafana/tempo-operator/api/config/v1alpha1"
    +	"github.com/grafana/tempo-operator/api/tempo/v1alpha1"
    +
     	"github.com/stretchr/testify/assert"
     	"github.com/stretchr/testify/require"
    +	authorizationv1 "k8s.io/api/authorization/v1"
     	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
     	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
     	"k8s.io/apimachinery/pkg/runtime"
     	"k8s.io/apimachinery/pkg/util/validation/field"
     	"sigs.k8s.io/controller-runtime/pkg/client"
     	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
    -
    -	configv1alpha1 "github.com/grafana/tempo-operator/api/config/v1alpha1"
    -	"github.com/grafana/tempo-operator/api/tempo/v1alpha1"
     )
     
     func TestMonolithicValidate(t *testing.T) {
    +	ctx := admission.NewContextWithRequest(context.Background(), admission.Request{})
    +
     	tests := []struct {
     		name       string
     		ctrlConfig configv1alpha1.ProjectConfig
    @@ -405,13 +408,19 @@ func TestMonolithicValidate(t *testing.T) {
     
     	for _, test := range tests {
     		t.Run(test.name, func(t *testing.T) {
    -			client := &k8sFake{}
    +			client := &k8sFake{
    +				subjectAccessReview: &authorizationv1.SubjectAccessReview{
    +					Status: authorizationv1.SubjectAccessReviewStatus{
    +						Allowed: true,
    +					},
    +				},
    +			}
     			v := &monolithicValidator{
     				client:     client,
     				ctrlConfig: test.ctrlConfig,
     			}
     
    -			warnings, errors := v.validateTempoMonolithic(context.Background(), test.tempo)
    +			warnings, errors := v.validateTempoMonolithic(ctx, test.tempo)
     			require.Equal(t, test.warnings, warnings)
     			require.Equal(t, test.errors, errors)
     		})
    
  • internal/webhooks/tempostack_webhook.go+13 2 modified
    @@ -283,7 +283,7 @@ func (v *validator) validateQueryFrontend(tempo v1alpha1.TempoStack) field.Error
     	return nil
     }
     
    -func (v *validator) validateGateway(tempo v1alpha1.TempoStack) field.ErrorList {
    +func (v *validator) validateGateway(ctx context.Context, tempo v1alpha1.TempoStack) field.ErrorList {
     	path := field.NewPath("spec").Child("template").Child("gateway").Child("enabled")
     	if tempo.Spec.Template.Gateway.Enabled {
     		if tempo.Spec.Template.QueryFrontend.JaegerQuery.Ingress.Type != v1alpha1.IngressTypeNone {
    @@ -321,6 +321,17 @@ func (v *validator) validateGateway(tempo v1alpha1.TempoStack) field.ErrorList {
     				"Cannot enable gateway and distributor TLS at the same time",
     			)}
     		}
    +
    +		if tempo.Spec.Tenants != nil && tempo.Spec.Tenants.Mode == v1alpha1.ModeOpenShift {
    +			err := validateGatewayOpenShiftModeRBAC(ctx, v.client)
    +			if err != nil {
    +				return field.ErrorList{field.Invalid(
    +					field.NewPath("spec").Child("tenants").Child("mode"),
    +					tempo.Spec.Tenants.Mode,
    +					fmt.Sprintf("Cannot enable OpenShift tenancy mode: %v", err),
    +				)}
    +			}
    +		}
     	}
     	return nil
     }
    @@ -483,7 +494,7 @@ func (v *validator) validate(ctx context.Context, obj runtime.Object) (admission
     
     	allErrors = append(allErrors, v.validateReplicationFactor(*tempo)...)
     	allErrors = append(allErrors, v.validateQueryFrontend(*tempo)...)
    -	allErrors = append(allErrors, v.validateGateway(*tempo)...)
    +	allErrors = append(allErrors, v.validateGateway(ctx, *tempo)...)
     	allErrors = append(allErrors, v.validateTenantConfigs(*tempo)...)
     	allErrors = append(allErrors, v.validateObservability(*tempo)...)
     	allErrors = append(allErrors, v.validateDeprecatedFields(*tempo)...)
    
  • internal/webhooks/tempostack_webhook_test.go+19 6 modified
    @@ -7,6 +7,7 @@ import (
     	"time"
     
     	"github.com/stretchr/testify/assert"
    +	authorizationv1 "k8s.io/api/authorization/v1"
     	corev1 "k8s.io/api/core/v1"
     	v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
     	apierrors "k8s.io/apimachinery/pkg/api/errors"
    @@ -1431,7 +1432,7 @@ func TestValidateGatewayAndJaegerQuery(t *testing.T) {
     	for _, test := range tests {
     		t.Run(test.name, func(t *testing.T) {
     			validator := &validator{ctrlConfig: configv1alpha1.ProjectConfig{}}
    -			errs := validator.validateGateway(test.input)
    +			errs := validator.validateGateway(context.Background(), test.input)
     			assert.Equal(t, test.expected, errs)
     		})
     	}
    @@ -2170,7 +2171,7 @@ func TestValidateReceiverTLSAndGateway(t *testing.T) {
     	for _, test := range tests {
     		t.Run(test.name, func(t *testing.T) {
     			validator := &validator{ctrlConfig: configv1alpha1.ProjectConfig{}}
    -			errs := validator.validateGateway(test.input)
    +			errs := validator.validateGateway(context.Background(), test.input)
     			assert.Equal(t, test.expected, errs)
     		})
     	}
    @@ -2284,13 +2285,25 @@ func TestWarning(t *testing.T) {
     }
     
     type k8sFake struct {
    -	secret          *corev1.Secret
    -	configmap       *corev1.ConfigMap
    -	tempoStack      *v1alpha1.TempoStack
    -	tempoMonolithic *v1alpha1.TempoMonolithic
    +	secret              *corev1.Secret
    +	configmap           *corev1.ConfigMap
    +	tempoStack          *v1alpha1.TempoStack
    +	tempoMonolithic     *v1alpha1.TempoMonolithic
    +	subjectAccessReview *authorizationv1.SubjectAccessReview
     	client.Client
     }
     
    +func (k *k8sFake) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
    +	switch typed := obj.(type) {
    +	case *authorizationv1.SubjectAccessReview:
    +		if k.subjectAccessReview != nil {
    +			k.subjectAccessReview.DeepCopyInto(typed)
    +			return nil
    +		}
    +	}
    +	return fmt.Errorf("mock: fails always")
    +}
    +
     func (k *k8sFake) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
     	switch typed := obj.(type) {
     	case *corev1.Secret:
    
  • internal/webhooks/validations.go+64 0 modified
    @@ -1,10 +1,18 @@
     package webhooks
     
     import (
    +	"context"
     	"fmt"
     
    +	"github.com/grafana/tempo-operator/internal/manifests/gateway"
    +
    +	authenticationv1 "k8s.io/api/authentication/v1"
    +	authorizationv1 "k8s.io/api/authorization/v1"
    +	rbacv1 "k8s.io/api/rbac/v1"
     	apierrors "k8s.io/apimachinery/pkg/api/errors"
     	"k8s.io/apimachinery/pkg/util/validation/field"
    +	"sigs.k8s.io/controller-runtime/pkg/client"
    +	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
     )
     
     const maxLabelLength = 63
    @@ -42,3 +50,59 @@ func validateTempoNameConflict(getFn func() error, instanceName string, to strin
     	}
     	return allErrs
     }
    +
    +func subjectAccessReviewsForClusterRole(user authenticationv1.UserInfo, clusterRole rbacv1.ClusterRole) []authorizationv1.SubjectAccessReview {
    +	reviews := []authorizationv1.SubjectAccessReview{}
    +	for _, rule := range clusterRole.Rules {
    +		for _, apiGroup := range rule.APIGroups {
    +			for _, resource := range rule.Resources {
    +				for _, verb := range rule.Verbs {
    +					reviews = append(reviews, authorizationv1.SubjectAccessReview{
    +						Spec: authorizationv1.SubjectAccessReviewSpec{
    +							UID:    user.UID,
    +							User:   user.Username,
    +							Groups: user.Groups,
    +							ResourceAttributes: &authorizationv1.ResourceAttributes{
    +								Group:    apiGroup,
    +								Resource: resource,
    +								Verb:     verb,
    +							},
    +						},
    +					})
    +				}
    +			}
    +		}
    +	}
    +
    +	return reviews
    +}
    +
    +// validateGatewayOpenShiftModeRBAC checks if the user requesting the change on the CR
    +// has already the permissions which the operator would grant to the ServiceAccount of the Tempo instance
    +// when enabling the OpenShift tenancy mode.
    +//
    +// In other words, the operator should not grant e.g. TokenReview permissions to the ServiceAccount of the Tempo instance
    +// if the user creating or modifying the TempoStack or TempoMonolithic doesn't have these permissions.
    +func validateGatewayOpenShiftModeRBAC(ctx context.Context, client client.Client) error {
    +	req, err := admission.RequestFromContext(ctx)
    +	if err != nil {
    +		return err
    +	}
    +
    +	user := req.UserInfo
    +	clusterRole := gateway.NewAccessReviewClusterRole("", map[string]string{})
    +	reviews := subjectAccessReviewsForClusterRole(user, *clusterRole)
    +
    +	for _, sar := range reviews {
    +		err := client.Create(ctx, &sar)
    +		if err != nil {
    +			return fmt.Errorf("failed to create subject access review: %w", err)
    +		}
    +
    +		if !sar.Status.Allowed {
    +			return fmt.Errorf("user %s does not have permission to %s %s.%s", user.Username, sar.Spec.ResourceAttributes.Verb, sar.Spec.ResourceAttributes.Resource, sar.Spec.ResourceAttributes.Group)
    +		}
    +	}
    +
    +	return nil
    +}
    

Vulnerability mechanics

Root cause

"The operator unconditionally creates a ClusterRole with TokenReview and SubjectAccessReview permissions when OpenShift tenancy mode is enabled, without verifying that the requesting user already holds those permissions."

Attack vector

An attacker with full access to their own namespace can deploy a TempoStack or TempoMonolithic custom resource with OpenShift tenancy mode enabled. The operator then creates a ServiceAccount, ClusterRole, and ClusterRoleBinding that grant TokenReview and SubjectAccessReview permissions to that ServiceAccount. The attacker extracts the ServiceAccount token and uses it to submit TokenReview and SubjectAccessReview requests against the Kubernetes API, revealing information about other users' permissions in the cluster. This is an information disclosure vulnerability [CWE-200] that does not enable privilege escalation or impersonation but can aid in reconnaissance for further attacks.

Affected code

The vulnerability is in the operator's handling of OpenShift tenancy mode for TempoStack and TempoMonolithic custom resources. The relevant code paths are in `internal/webhooks/tempostack_webhook.go` (the `validateGateway` function) and `internal/webhooks/tempomonolithic_webhook.go` (the `validateMultitenancy` function), which previously did not check whether the requesting user had the permissions that would be granted to the Tempo ServiceAccount.

What the fix does

The patch adds a new admission webhook validation function `validateGatewayOpenShiftModeRBAC` in `internal/webhooks/validations.go` [patch_id=325924]. Before allowing a TempoStack or TempoMonolithic resource to be created or modified with OpenShift tenancy mode, the webhook iterates over all rules in the ClusterRole that would be granted to the Tempo ServiceAccount and creates a SubjectAccessReview for each permission. If the requesting user does not already hold those permissions, the webhook rejects the request with an error message. This ensures the operator never grants permissions (such as TokenReview and SubjectAccessReview) that exceed what the user themselves possesses, closing the information-disclosure vector.

Preconditions

  • authAttacker must have full access to their own Kubernetes namespace to deploy a TempoStack or TempoMonolithic custom resource.
  • configThe Tempo operator must be deployed in the cluster and the attacker must enable OpenShift tenancy mode on their Tempo instance.
  • networkAttacker must be able to reach the Kubernetes API server to submit TokenReview and SubjectAccessReview requests using the extracted ServiceAccount token.

Generated on May 19, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

8

News mentions

0

No linked articles in our index yet.