Input validation / metadata.name derivation consistency for global variables

MEDIUM
grafana/grafana
Commit: cc7ea5279b9a
Affected: <=12.4.0
2026-05-22 17:25 UTC

Description

The commit adds enforcement that global variable metadata.name must be derived from the variable's spec name and the folder scope, and rejects mismatches on create/update. Specifically, it derives a canonical name (status--<folderUID> when a folder is present) and validates any provided metadata.name against that derived value. This blocks a potential input validation vulnerability where an attacker could supply a mismatched metadata.name (or omit it to rely on server-side derivation) and cause inconsistent ownership/namespace mapping or misconfiguration. The change also tightens update behavior to prevent changing the effective name or folder scope of an existing variable. Overall, this is a real security-oriented validation fix, not just a dependency bump or test-only change.

Proof of Concept

Proof of concept (actionable steps): Prerequisites: - Grafana instance containing the global variable API for dashboard variables (v2beta1) as patched by this commit. - An authenticated client token with permissions to create Variables. - A folder with UID "folder-z" existing in the cluster. 1) Attempt to create a global variable with a mismatched metadata.name: POST /apis/dashboard.k8s.io/v2beta1/Variable Content-Type: application/json Authorization: Bearer <token> { "metadata": { "name": "status", // intentionally not matching derived name "annotations": { "grafana.app/folder": "folder-z" } }, "spec": { "spec": { "name": "status" } } } Expected (pre-fix behavior on vulnerable versions): The server would accept this payload and create the variable with metadata.name "status" even though the derived value would be "status--folder-z". Expected (patched behavior): The server rejects with a BadRequest explaining that metadata.name does not match the derived value for the given spec and folder (or that omission is required to allow server-side derivation). 2) Create with metadata.name omitted to allow server-side derivation: POST /apis/dashboard.k8s.io/v2beta1/Variable Content-Type: application/json Authorization: Bearer <token> { "metadata": { "annotations": { "grafana.app/folder": "folder-z" } }, "spec": { "spec": { "name": "status" } } } Expected: The server derives metadata.name as "status--folder-z" and creates the resource if allowed by other validations. 3) Attempt to change the name to a mismatched value on update (to demonstrate protection against name spoofing after creation): PUT /apis/dashboard.k8s.io/v2beta1/Variable/<name> ... payload ... Expected: The update is rejected if the derived name would change or if the new metadata.name does not align with the current spec.folder-derived name (depending on the exact API semantics).

Commit Details

Author: Haris Rozajac

Date: 2026-05-22 17:01 UTC

Message:

Global variable service: derive metadata.name from spec/folder (#125298) * derive metadata.name from spec/folder * update tests

Triage Assessment

Vulnerability Type: Input validation

Confidence: MEDIUM

Reasoning:

The commit adds logic to derive and validate metadata.name for global variables based on spec.name and folder, and rejects mismatches. This tightens input validation and prevents inconsistent naming that could bypass access/validation rules or cause misconfiguration. It also updates tests accordingly. While not a classic vulnerability like RCE, it improves security by enforcing correct metadata ownership/namespace mapping.

Verification Assessment

Vulnerability Type: Input validation / metadata.name derivation consistency for global variables

Confidence: MEDIUM

Affected Versions: <=12.4.0

Code Diff

diff --git a/apps/dashboard/kinds/globalvariable.cue b/apps/dashboard/kinds/globalvariable.cue index 55634fb0c0f37..5c95698add675 100644 --- a/apps/dashboard/kinds/globalvariable.cue +++ b/apps/dashboard/kinds/globalvariable.cue @@ -7,6 +7,13 @@ import ( globalVariableV2beta1: { kind: "Variable" pluralName: "Variables" + // API contract: + // - spec.spec.name is the logical variable name input. + // - metadata.annotations["grafana.app/folder"] carries optional folder scope. + // - metadata.name may be omitted on CREATE; the server derives it as: + // - global: spec.spec.name + // - folder: spec.spec.name + "--" + folderUID + // - when metadata.name is provided, it must match the derived value. //TODO: // Adding selectableFields here causes the codegen to fail because it's a union parent path. // Until grafana-app-sdk supports selectableFields through union parent paths, diff --git a/pkg/registry/apis/dashboard/mutate.go b/pkg/registry/apis/dashboard/mutate.go index 5ef9a9b772a8c..8d895d0d56f0d 100644 --- a/pkg/registry/apis/dashboard/mutate.go +++ b/pkg/registry/apis/dashboard/mutate.go @@ -196,6 +196,10 @@ func mutateVariable(a admission.Attributes) error { } variable.SetLabels(labels) + if a.GetOperation() == admission.Create && variable.GetName() == "" { + variable.SetName(deriveVariableMetadataName(getVariableName(variable.Spec), meta.GetFolder())) + } + return nil } diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 37d197df63d24..d6f6dfd877007 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -121,7 +121,6 @@ type DashboardsAPIBuilder struct { minRefreshInterval string dualWriter dualwrite.Service folderClientProvider client.K8sHandlerProvider - variableClientProvider client.K8sHandlerProvider libraryPanels libraryelements.Service // for legacy library panels libraryPanelsEnabled bool publicDashboardService publicdashboards.Service @@ -168,7 +167,6 @@ func RegisterAPIService( dbp := legacysql.NewDatabaseProvider(sql) namespacer := request.GetNamespaceMapper(cfg) folderClient := client.NewK8sHandler(request.GetNamespaceMapper(cfg), folders.FolderResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, userService, unified) - variableClient := client.NewK8sHandler(namespacer, dashv2beta1.VariableResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, userService, unified) dashboardClient := client.NewK8sHandler(namespacer, dashv1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, userService, unified) snapshotOptions := dashv0.SnapshotSharingOptions{ @@ -195,7 +193,6 @@ func RegisterAPIService( dualWriter: dual, dashboardK8sClient: dashboardClient, folderClientProvider: newSimpleClientProvider(folderClient), - variableClientProvider: newSimpleClientProvider(variableClient), libraryPanels: libraryPanels, libraryPanelsEnabled: features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs), // nolint:staticcheck publicDashboardService: publicDashboardService, @@ -588,6 +585,10 @@ func (b *DashboardsAPIBuilder) validateVariableCreate(ctx context.Context, a adm return fmt.Errorf("error getting variable meta accessor: %w", err) } + if err := validateVariableMetadataName(variable.GetName(), getVariableName(variable.Spec), accessor.GetFolder()); err != nil { + return apierrors.NewBadRequest(err.Error()) + } + if !a.IsDryRun() && accessor.GetFolder() != "" { id, err := identity.GetRequester(ctx) if err != nil { @@ -603,17 +604,6 @@ func (b *DashboardsAPIBuilder) validateVariableCreate(ctx context.Context, a adm } } - if !a.IsDryRun() { - namespace := accessor.GetNamespace() - if namespace == "" { - namespace = a.GetNamespace() - } - - if err := b.validateVariableNameUniqueness(ctx, namespace, variable, variable.GetName()); err != nil { - return apierrors.NewBadRequest(err.Error()) - } - } - return nil } @@ -646,35 +636,12 @@ func (b *DashboardsAPIBuilder) validateVariableUpdate(ctx context.Context, a adm return fmt.Errorf("error getting new variable meta accessor: %w", err) } - if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() && newAccessor.GetFolder() != "" { - id, err := identity.GetRequester(ctx) - if err != nil { - return fmt.Errorf("error getting requester: %w", err) - } - - if err := b.verifyFolderAccessPermissions(ctx, id, newAccessor.GetFolder()); err != nil { - return err - } - - nsInfo, err := authlib.ParseNamespace(newAccessor.GetNamespace()) - if err != nil { - return fmt.Errorf("failed to parse namespace: %w", err) - } - - if _, err := b.validateFolderExists(ctx, newAccessor.GetFolder(), nsInfo.OrgID); err != nil { - return apierrors.NewNotFound(folders.FolderResourceInfo.GroupResource(), newAccessor.GetFolder()) - } + if getVariableName(newVariable.Spec) != getVariableName(oldVariable.Spec) { + return apierrors.NewBadRequest("spec.spec.name cannot be changed; delete the variable and create a new one") } - if !a.IsDryRun() { - namespace := newAccessor.GetNamespace() - if namespace == "" { - namespace = a.GetNamespace() - } - - if err := b.validateVariableNameUniqueness(ctx, namespace, newVariable, newVariable.GetName()); err != nil { - return apierrors.NewBadRequest(err.Error()) - } + if newAccessor.GetFolder() != oldAccessor.GetFolder() { + return apierrors.NewBadRequest("folder scope cannot be changed; delete the variable and create a new one") } return nil @@ -948,7 +915,7 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver } storage := apiGroupInfo.VersionedResourcesStorageMap[dashv2beta1.VERSION] - storage[dashv2beta1.VariableResourceInfo.StoragePath()] = newVariableStorage(gvStore, b.variableClientProvider) + storage[dashv2beta1.VariableResourceInfo.StoragePath()] = gvStore } return nil diff --git a/pkg/registry/apis/dashboard/variable.go b/pkg/registry/apis/dashboard/variable.go index f0d6f90dd26d0..34915843be5ac 100644 --- a/pkg/registry/apis/dashboard/variable.go +++ b/pkg/registry/apis/dashboard/variable.go @@ -10,6 +10,8 @@ import ( var variableNameFormat = regexp.MustCompile(`^\w+$`) +const variableMetadataNameMaxLength = 253 + // walkVariableKinds traverses the VariableSpec union in a single pass and // returns the spec name of whichever kind is populated (empty if none) along // with the total number of populated kinds. Centralising the walk here keeps @@ -61,6 +63,39 @@ func getVariableName(spec dashv2beta1.VariableSpec) string { return name } +func deriveVariableMetadataName(specName, folderUID string) string { + if folderUID == "" { + return specName + } + return specName + "--" + folderUID +} + +func validateVariableMetadataName(gotName, specName, folderUID string) error { + expectedName := deriveVariableMetadataName(specName, folderUID) + if len(expectedName) > variableMetadataNameMaxLength { + return fmt.Errorf("derived metadata.name exceeds maximum length (%d)", variableMetadataNameMaxLength) + } + if gotName == "" || gotName == expectedName { + return nil + } + + if folderUID == "" { + return fmt.Errorf( + "metadata.name %q does not match the name required for spec.spec.name %q: expected %q (omit metadata.name to let the server set it)", + gotName, + specName, + expectedName, + ) + } + return fmt.Errorf( + "metadata.name %q does not match the name required for spec.spec.name %q in folder %q: expected %q (omit metadata.name to let the server set it)", + gotName, + specName, + folderUID, + expectedName, + ) +} + func validateVariable(variable *dashv2beta1.Variable) error { if variable == nil { return fmt.Errorf("variable payload is required") diff --git a/pkg/registry/apis/dashboard/variable_mutation_test.go b/pkg/registry/apis/dashboard/variable_mutation_test.go index a5f71da45bc92..a8b7f54363ff7 100644 --- a/pkg/registry/apis/dashboard/variable_mutation_test.go +++ b/pkg/registry/apis/dashboard/variable_mutation_test.go @@ -54,4 +54,23 @@ func TestDashboardsAPIBuilderMutateVariable(t *testing.T) { ), nil) require.NoError(t, err) require.NotContains(t, v.GetLabels(), variableFolderLabelKey) + + // When metadata.name is omitted on create, mutation derives it from spec name + folder. + v = newCustomVariable("status", "") + v.SetAnnotations(map[string]string{utils.AnnoKeyFolder: "folder-z"}) + err = builder.Mutate(context.Background(), admission.NewAttributesRecord( + v, + nil, + dashv2beta1.VariableResourceInfo.GroupVersionKind(), + "stacks-1", + v.GetName(), + dashv2beta1.VariableResourceInfo.GroupVersionResource(), + "", + admission.Create, + &metav1.CreateOptions{}, + false, + nil, + ), nil) + require.NoError(t, err) + require.Equal(t, "status--folder-z", v.GetName()) } diff --git a/pkg/registry/apis/dashboard/variable_storage.go b/pkg/registry/apis/dashboard/variable_storage.go deleted file mode 100644 index 19663ea1a10f7..0000000000000 --- a/pkg/registry/apis/dashboard/variable_storage.go +++ /dev/null @@ -1,107 +0,0 @@ -package dashboard - -import ( - "context" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic/registry" - "k8s.io/apiserver/pkg/registry/rest" - - "github.com/grafana/grafana-app-sdk/logging" - dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" - "github.com/grafana/grafana/pkg/services/apiserver/client" -) - -// variableStorage wraps *registry.Store so that Create performs a post-write -// uniqueness verification. The admission-time uniqueness check in -// validateVariableNameUniqueness is TOCTOU-vulnerable: two concurrent Creates -// for the same (spec.spec.name, folder) can both see an empty list and both -// commit. This wrapper re-lists after the write and, if duplicates are -// observed, applies a deterministic tie-break (earliest creationTimestamp, -// then lowest UID). Both verifiers in a race reach the same conclusion, so -// exactly one object survives; the loser deletes itself and returns -// AlreadyExists to the client. The admission-level check remains the fast -// path for the common non-racing case. -type variableStorage struct { - *registry.Store - provider client.K8sHandlerProvider - - // innerCreate and innerDelete indirect through fields so tests can inject - // fakes without constructing a real *registry.Store. In production they - // point at the embedded store's methods. - innerCreate func(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) - innerDelete func(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) -} - -var _ rest.Creater = (*variableStorage)(nil) - -func newVariableStorage(store *registry.Store, provider client.K8sHandlerProvider) *variableStorage { - return &variableStorage{ - Store: store, - provider: provider, - innerCreate: store.Create, - innerDelete: store.Delete, - } -} - -func (s *variableStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { - created, err := s.innerCreate(ctx, obj, createValidation, options) - if err != nil { - return created, err - } - - // Dry-run does not persist, so the post-create list cannot see it and the - // verification is meaningless. The admission-time check already ran. - if options != nil && len(options.DryRun) > 0 { - return created, nil - } - - if s.provider == nil { - return created, nil - } - - variable, ok := created.(*dashv2beta1.Variable) - if !ok { - return created, nil - } - - lost, verifyErr := resolveVariableNameConflictAfterCreate(ctx, s.provider, variable) - if verifyErr != nil { - // Degrade gracefully: the admission-level check is the primary - // guarantee. Do not fail the user's write because the post-write - // verification could not be completed. - logging.FromContext(ctx).Warn( - "variable post-create uniqueness verification failed; keeping object", - "name", variable.GetName(), - "namespace", variable.GetNamespace(), - "err", verifyErr, - ) - return created, nil - } - - if !lost { - return created, nil - } - - specName := getVariableName(variable.Spec) - uid := variable.GetUID() - deleteOpts := &metav1.DeleteOptions{ - Preconditions: &metav1.Preconditions{UID: &uid}, - } - if _, _, deleteErr := s.innerDelete(ctx, variable.GetName(), nil, deleteOpts); deleteErr != nil { - // The losing object may be briefly orphaned if this delete fails; the - // next admission-level check on any subsequent create will catch it. - // Surface AlreadyExists to the client either way - logging.FromContext(ctx).Error( - "failed to delete variable after losing uniqueness tie-break", - "name", variable.GetName(), - "namespace", variable.GetNamespace(), - "uid", string(uid), - "err", deleteErr, - ) - } - - return nil, apierrors.NewAlreadyExists(dashv2beta1.VariableResourceInfo.GroupResource(), specName) -} diff --git a/pkg/registry/apis/dashboard/variable_storage_test.go b/pkg/registry/apis/dashboard/variable_storage_test.go deleted file mode 100644 index beda16b820b9d..0000000000000 --- a/pkg/registry/apis/dashboard/variable_storage_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package dashboard - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apiserver/pkg/registry/rest" - - "github.com/grafana/grafana/pkg/services/apiserver/client" -) - -func TestVariableStorageCreate(t *testing.T) { - ctx := context.Background() - createdTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - - buildCreatedVariable := func() runtime.Object { - return newCreatedVariable("region", "region-created", "uid-created", "42", "", createdTime) - } - - t.Run("happy path: no duplicate, returns created object and does not call Delete", func(t *testing.T) { - handler := &client.MockK8sHandler{} - handler.On("List", mock.Anything, int64(1), mock.Anything). - Return(&unstructured.UnstructuredList{ - Items: []unstructured.Unstructured{ - makeVariableListItem("region-created", "uid-created", "", createdTime), - }, - }, nil).Once() - - calls := &storeCallRecorder{} - s := &variableStorage{ - provider: &staticHandlerProvider{handler: handler}, - innerCreate: calls.create(buildCreated ... [truncated]
← Back to Alerts View on GitHub →