TOCTOU (Race condition) in authorization/mutation flow

HIGH
grafana/grafana
Commit: eda920b8f54c
Affected: <=12.4.0 (Grafana 12.4.x and earlier on the affected line)
2026-05-15 15:04 UTC

Description

The commit is marked as addressing a TOCTOU (time-of-check-to-time-of-use) race condition in the dashboard/global template variables service, including guard changes for folder handling, access checks, and limiting mutations to Admins/Editors. The diffs show additions aimed at guarding access (guard folder handler, check for accessClient) and changes to how global variables are handled across v2beta1, plus tests around folder access. The presence of a specific TOCTOU fix in the changelog and the accompanying code changes (guarded access paths, permission checks around mutations, and tests) indicate this is a genuine vulnerability fix addressing a race condition rather than a mere dependency bump or non-security code cleanup.

Proof of Concept

Go PoC illustrating a time-of-check-to-time-of-use (TOCTOU) race between a permission check and a subsequent mutation. The vulnerable pattern: check permissions, then perform mutation without re-checking under a race window where another actor can elevate permissions just after the check but before the mutation executes. In real Grafana code this would map to an endpoint/folder mutation where an access check is performed, followed by a mutation that proceeds without re-verifying permissions, allowing an attacker to gain unintended mutation capability if they escalate rights in the tiny window between the check and the action. Code (illustrative, not Grafana-specific): package main import ( "fmt" "sync" "time" ) // Simulated permission store (not mutex-protected for the TOCTOU demonstration) var permissions = map[string]bool{ "attacker": false } var m sync.Mutex // hasAccess simulates a check that could be raced with an attacker escalting privileges func hasAccess(user string) bool { // No synchronization here to reproduce TOCTOU; read is not atomic with respect to updates return permissions[user] } // grantAccess simulates an attacker increasing privileges just after the check has been performed func grantAccess(user string) { time.Sleep(5 * time.Millisecond) m.Lock() permissions[user] = true m.Unlock() fmt.Println("[attacker] granted access for", user) } // mutateResource represents the action that should be gated by the permission check // In a TOCTOU vulnerability this would run even if the permission had not been valid at the moment of mutation func mutateResource(user string) { // No re-check; assumes permission was granted at check time fmt.Println("[system] resource mutated by", user) } // handleCreate demonstrates the vulnerable pattern: check then mutate without re-check under race func handleCreate(user string) { if !hasAccess(user) { fmt.Println("[system] access denied for", user) return } // Vulnerable: no re-check of permission before mutation mutateResource(user) } func main() { go grantAccess("attacker") // Small delay to create a race window between check and mutation time.Sleep(3 * time.Millisecond) handleCreate("attacker") }

Commit Details

Author: Haris Rozajac

Date: 2026-05-15 14:04 UTC

Message:

Dashboard: Global template variables service (#122496) * init working version * register spec.spec.name as field selector * add folder as label selector * the scope-aware uniquenes * rename and cleanup * limit mutations to Admins and Editors * use with stable v2 * fix * add folder access permissions on POST; cleanup * add folder permissions test * run feature toggles * fix test * staticcheck * clean up global var references from v2beta1 * fix TOCTOU vuln * add comment regarding storage registration * go lint fix * remove duplication for simple client providers * add helper that returns name and count * add comment about variable spec wrapper * add format check for var name * add scope change tests * gen jsonnet * update feature toggles gen * update manifest * update test * move under v2beta1 * cleanup * openapi regen * update manifest * hack for selectableFields * Delete v2 stable hack * guard folder handler * check for accessClient --------- Co-authored-by: Cursor <cursoragent@cursor.com>

Triage Assessment

Vulnerability Type: Race condition (TOCTOU)

Confidence: HIGH

Reasoning:

Commit message explicitly mentions fixing a TOCTOU vulnerability and includes changes labeled as security-related (guarding folder handler, access checks, limiting mutations to Admins/Editors). The diff shows access-control and synchronization-related tweaks in the dashboard/global variables functionality, which align with race-condition and authorization improvements. Therefore, this is a security fix addressing a race condition and related access control issues.

Verification Assessment

Vulnerability Type: TOCTOU (Race condition) in authorization/mutation flow

Confidence: HIGH

Affected Versions: <=12.4.0 (Grafana 12.4.x and earlier on the affected line)

Code Diff

diff --git a/apps/dashboard/Makefile b/apps/dashboard/Makefile index fe35c4971469..e176303e8416 100644 --- a/apps/dashboard/Makefile +++ b/apps/dashboard/Makefile @@ -22,6 +22,10 @@ post-generate-cleanup: ## Clean up the generated code @rm ../../packages/grafana-schema/src/schema/dashboard/v1beta1/types.spec.gen.ts @cp ./tshack/v1alpha1_spec_gen.ts ../../packages/grafana-schema/src/schema/dashboard/v1beta1/types.spec.gen.ts + # Variable spec currently requires a TS shim to expose Spec/defaultSpec. + @rm -f ../../packages/grafana-schema/src/schema/variable/v2beta1/types.spec.gen.ts + @cp ./tshack/variable_v2beta1_spec_gen.ts ../../packages/grafana-schema/src/schema/variable/v2beta1/types.spec.gen.ts + # Remove auto-generated DeepCopy and DeepCopyInto methods for Spec for v0alpha1. @sed -e '/\/\/ DeepCopy creates a full deep copy of Spec/,+5d' ./pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go > ./pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go.tmp @sed -e '/\/\/ DeepCopyInto deep copies Spec into another Spec object/,+3d' ./pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go.tmp > ./pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go.tmp2 @@ -61,6 +65,9 @@ post-generate-cleanup: ## Clean up the generated code @echo "" >> ./pkg/apis/dashboard/v2/dashboard_spec.cue @cat ./kinds/v2/dashboard_spec.cue >> ./pkg/apis/dashboard/v2/dashboard_spec.cue + # Temporary workaround until grafana-app-sdk supports selectableFields through union parent paths. + @bash ./cuehack/patch_variable_selectable_fields.sh ./pkg/apis/dashboard_manifest.go + @# Remove generated files for v1beta1 since it's a thin wrapper around v1 @rm -f ./pkg/apis/dashboard/v1beta1/dashboard_codec_gen.go @rm -f ./pkg/apis/dashboard/v1beta1/dashboard_object_gen.go diff --git a/apps/dashboard/cuehack/patch_variable_selectable_fields.sh b/apps/dashboard/cuehack/patch_variable_selectable_fields.sh new file mode 100644 index 000000000000..80936dd869b5 --- /dev/null +++ b/apps/dashboard/cuehack/patch_variable_selectable_fields.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +manifest_path="${1:-./pkg/apis/dashboard_manifest.go}" + +tmp_file="$(mktemp)" +trap 'rm -f "$tmp_file"' EXIT + +awk ' +BEGIN { patched = 0 } +{ + print $0 + + if (!patched && $0 ~ /Schema: &versionSchemaVariablev2beta1,/) { + if (getline nextline > 0) { + if (nextline ~ /^[[:space:]]*SelectableFields: \[\]string\{/) { + print nextline + } else { + print "\t\t\t\t\tSelectableFields: []string{" + print "\t\t\t\t\t\t\"spec.spec.name\"," + print "\t\t\t\t\t}," + print nextline + } + patched = 1 + } + } +} +' "$manifest_path" > "$tmp_file" + +mv "$tmp_file" "$manifest_path" diff --git a/apps/dashboard/kinds/globalvariable.cue b/apps/dashboard/kinds/globalvariable.cue new file mode 100644 index 000000000000..55634fb0c0f3 --- /dev/null +++ b/apps/dashboard/kinds/globalvariable.cue @@ -0,0 +1,27 @@ +package kinds + +import ( + v2beta1 "github.com/grafana/grafana/sdkkinds/dashboard/v2beta1" +) + +globalVariableV2beta1: { + kind: "Variable" + pluralName: "Variables" + //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, + // we're not adding it here and patching the manifest.go file instead with + // apps/dashboard/cuehack/patch_variable_selectable_fields.sh + // selectableFields: [ + // "spec.spec.name", + // ] + validation: { + operations: ["CREATE", "UPDATE"] + } + mutation: { + operations: ["CREATE", "UPDATE"] + } + schema: { + spec: v2beta1.VariableKind + } +} diff --git a/apps/dashboard/kinds/manifest.cue b/apps/dashboard/kinds/manifest.cue index ff2567f4fa7e..1d592caf4a80 100644 --- a/apps/dashboard/kinds/manifest.cue +++ b/apps/dashboard/kinds/manifest.cue @@ -103,6 +103,7 @@ manifest: { status: DashboardStatus } }, + globalVariableV2beta1, ] } "v2": { diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/register.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/register.go index 29dfb0a3769b..ed7f6b270e50 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/register.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/register.go @@ -43,6 +43,32 @@ var DashboardResourceInfo = utils.NewResourceInfo(GROUP, VERSION, }, ) +var VariableResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "variables", "variable", "Variable", + func() runtime.Object { return &Variable{} }, + func() runtime.Object { return &VariableList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Variable Kind", Type: "string", Format: "string", Description: "The global variable type"}, + {Name: "Created At", Type: "date"}, + }, + Reader: func(obj any) ([]interface{}, error) { + variable, ok := obj.(*Variable) + if ok { + if variable != nil { + return []interface{}{ + variable.Name, + getVariableKindName(variable.Spec), + variable.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + } + } + return nil, fmt.Errorf("expected variable") + }, + }, +) + var ( SchemeBuilder runtime.SchemeBuilder localSchemeBuilder = &SchemeBuilder @@ -60,6 +86,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &Dashboard{}, &DashboardList{}, &DashboardWithAccessInfo{}, + &Variable{}, + &VariableList{}, &metav1.PartialObjectMetadata{}, &metav1.PartialObjectMetadataList{}, ) @@ -67,6 +95,31 @@ func addKnownTypes(scheme *runtime.Scheme) error { return nil } +func getVariableKindName(spec VariableSpec) string { + switch { + case spec.QueryVariableKind != nil: + return spec.QueryVariableKind.Kind + case spec.TextVariableKind != nil: + return spec.TextVariableKind.Kind + case spec.ConstantVariableKind != nil: + return spec.ConstantVariableKind.Kind + case spec.DatasourceVariableKind != nil: + return spec.DatasourceVariableKind.Kind + case spec.IntervalVariableKind != nil: + return spec.IntervalVariableKind.Kind + case spec.CustomVariableKind != nil: + return spec.CustomVariableKind.Kind + case spec.GroupByVariableKind != nil: + return spec.GroupByVariableKind.Kind + case spec.AdhocVariableKind != nil: + return spec.AdhocVariableKind.Kind + case spec.SwitchVariableKind != nil: + return spec.SwitchVariableKind.Kind + default: + return "" + } +} + func addDefaultingFuncs(scheme *runtime.Scheme) error { return RegisterDefaults(scheme) } diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/variable_client_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/variable_client_gen.go new file mode 100644 index 000000000000..139f42198512 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/variable_client_gen.go @@ -0,0 +1,80 @@ +package v2beta1 + +import ( + "context" + + "github.com/grafana/grafana-app-sdk/resource" +) + +type VariableClient struct { + client *resource.TypedClient[*Variable, *VariableList] +} + +func NewVariableClient(client resource.Client) *VariableClient { + return &VariableClient{ + client: resource.NewTypedClient[*Variable, *VariableList](client, VariableKind()), + } +} + +func NewVariableClientFromGenerator(generator resource.ClientGenerator) (*VariableClient, error) { + c, err := generator.ClientFor(VariableKind()) + if err != nil { + return nil, err + } + return NewVariableClient(c), nil +} + +func (c *VariableClient) Get(ctx context.Context, identifier resource.Identifier) (*Variable, error) { + return c.client.Get(ctx, identifier) +} + +func (c *VariableClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*VariableList, error) { + return c.client.List(ctx, namespace, opts) +} + +func (c *VariableClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*VariableList, error) { + resp, err := c.client.List(ctx, namespace, resource.ListOptions{ + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + for resp.GetContinue() != "" { + page, err := c.client.List(ctx, namespace, resource.ListOptions{ + Continue: resp.GetContinue(), + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + resp.SetContinue(page.GetContinue()) + resp.SetResourceVersion(page.GetResourceVersion()) + resp.SetItems(append(resp.GetItems(), page.GetItems()...)) + } + return resp, nil +} + +func (c *VariableClient) Create(ctx context.Context, obj *Variable, opts resource.CreateOptions) (*Variable, error) { + // Make sure apiVersion and kind are set + obj.APIVersion = GroupVersion.Identifier() + obj.Kind = VariableKind().Kind() + return c.client.Create(ctx, obj, opts) +} + +func (c *VariableClient) Update(ctx context.Context, obj *Variable, opts resource.UpdateOptions) (*Variable, error) { + return c.client.Update(ctx, obj, opts) +} + +func (c *VariableClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*Variable, error) { + return c.client.Patch(ctx, identifier, req, opts) +} + +func (c *VariableClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { + return c.client.Delete(ctx, identifier, opts) +} diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/variable_codec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/variable_codec_gen.go new file mode 100644 index 000000000000..e2427b83bd8b --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/variable_codec_gen.go @@ -0,0 +1,28 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v2beta1 + +import ( + "encoding/json" + "io" + + "github.com/grafana/grafana-app-sdk/resource" +) + +// VariableJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding +type VariableJSONCodec struct{} + +// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` +func (*VariableJSONCodec) Read(reader io.Reader, into resource.Object) error { + return json.NewDecoder(reader).Decode(into) +} + +// Write writes JSON-encoded bytes into `writer` marshaled from `from` +func (*VariableJSONCodec) Write(writer io.Writer, from resource.Object) error { + return json.NewEncoder(writer).Encode(from) +} + +// Interface compliance checks +var _ resource.Codec = &VariableJSONCodec{} diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/variable_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/variable_object_gen.go new file mode 100644 index 000000000000..d514248775c2 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/variable_object_gen.go @@ -0,0 +1,307 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v2beta1 + +import ( + "fmt" + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "time" +) + +// +k8s:openapi-gen=true +type Variable struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ObjectMeta `json:"metadata" yaml:"metadata"` + + // Spec is the spec of the Variable + Spec VariableSpec `json:"spec" yaml:"spec"` +} + +func NewVariable() *Variable { + return &Variable{ + Spec: *NewVariableSpec(), + } +} + +func (o *Variable) GetSpec() any { + return o.Spec +} + +func (o *Variable) SetSpec(spec any) error { + cast, ok := spec.(VariableSpec) + if !ok { + return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) + } + o.Spec = cast + return nil +} + +func (o *Variable) GetSubresources() map[string]any { + return map[string]any{} +} + +func (o *Variable) GetSubresource(name string) (any, bool) { + switch name { + default: + return nil, false + } +} + +func (o *Variable) SetSubresource(name string, value any) error { + switch name { + default: + return fmt.Errorf("subresource '%s' does not exist", name) + } +} + +func (o *Variable) GetStaticMetadata() resource.StaticMetadata { + gvk := o.GroupVersionKind() + return resource.StaticMetadata{ + Name: o.ObjectMeta.Name, + Namespace: o.ObjectMeta.Namespace, + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + } +} + +func (o *Variable) SetStaticMetadata(metadata resource.StaticMetadata) { + o.Name = metadata.Name + o.Namespace = metadata.Namespace + o.SetGroupVersionKind(schema.GroupVersionKind{ + Group: metadata.Group, + Version: metadata.Version, + Kind: metadata.Kind, + }) +} + +func (o *Variable) GetCommonMetadata() resource.CommonMetadata { + dt := o.DeletionTimestamp + var deletionTimestamp *time.Time + if dt != nil { + deletionTimestamp = &dt.Time + } + // Legacy ExtraFields support + extraFields := make(map[string]any) + if o.Annotations != nil { + extraFields["annotations"] = o.Annotations + } + if o.ManagedFields != nil { + extraFields["managedFields"] = o.ManagedFields + } + if o.OwnerReferences != nil { + extraFields["ownerReferences"] = o.OwnerReferences + } + return resource.CommonMetadata{ + UID: string(o.UID), + ResourceVersion: o.ResourceVersion, + Generation: o.Generation, + Labels: o.Labels, + CreationTimestamp: o.CreationTimestamp.Time, + DeletionTimestamp: deletionTimestamp, + Finalizers: o.Finalizers, + UpdateTimestamp: o.GetUpdateTimestamp(), + CreatedBy: o.GetCreatedBy(), + UpdatedBy: o.GetUpdatedBy(), + ExtraFields: extraFields, + } +} + +func (o *Variable) SetCommonMetadata(metadata resource.CommonMetadata) { + o.UID = types.UID(metadata.UID) + o.ResourceVersion = metadata.ResourceVersion + o.Generation = metadata.Generation + o.Labels = metadata.Labels + o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) + if metadata.DeletionTimestamp != nil { + dt := metav1.NewTime(*metadata.DeletionTimestamp) + o.DeletionTimestamp = &dt + } else { + o.DeletionTimestamp = nil + } + o.Finalizers = metadata.Finalizers + if o.Annotations == nil { + o.Annotations = make(map[string]string) + } + if !metadata.UpdateTimestamp.IsZero() { + o.SetUpdateTimestamp(metadata.UpdateTimestamp) + } + if metadata.CreatedBy != "" { + o.SetCreatedBy(metadata.CreatedBy) + } + if metadata.UpdatedBy != "" { + o.SetUpdatedBy(metadata.UpdatedBy) + } + // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields + if metadata.ExtraFields != nil { + if annotations, ok := metadata.ExtraFields["annotations"]; ok { + if cast, ok := annotations.(map[string]string); ok { + o.Annotations = cast + } + } + if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { + if cast, ok := man ... [truncated]
← Back to Alerts View on GitHub →