Information Disclosure

MEDIUM
grafana/grafana
Commit: c04d750c6f22
Affected: <=12.4.0
2026-04-03 21:00 UTC

Description

The commit fixes an information disclosure risk in the trash (ListRequest_TRASH) listing. It ensures that only resources whose latest event is a deletion are included in trash, filters out provisioned (non-deleted) items, and sorts results by resource-version in descending order for proper pagination. The previous behavior could expose non-deleted resources or provisioned artifacts when listing trash, leading to unintended information disclosure. The changes also adjust pagination to be RV-based and add tests to validate the behavior.

Proof of Concept

PoC (Go-like pseudo-code demonstrating prior vulnerability exposure in trash listing): // Prerequisites: Grafana storage backend (unified-storage) in a vulnerable version (<=12.4.0). // 1) Create a normal resource and delete it to place it in trash. // 2) Create a resource marked as provisioned (annotation AnnoKeyManagerKind = "repo"). // 3) Delete the provisioned resource to also place it in trash. // 4) List trash and observe whether the provisioned (non-deleted) resource appears in results. package main import ( "context" "fmt" // Assume access to Grafana's storage backend test helpers and proto types ) func main() { ctx := context.Background() backend := setupTestStorageBackend(/* t or config */) // Step 1: add a normal resource and delete it (should be in trash) normalName := "normal-res" normalObj := createTestObjectWithName(normalName, "default", "data-normal") meta, _ := utils.MetaAccessor(normalObj) rvAdd, _ := backend.WriteEvent(ctx, WriteEvent{ Type: resourcepb.WatchEvent_ADDED, Key: &resourcepb.ResourceKey{Namespace: "default", Group: "apps", Resource: "resources", Name: normalName}, Value: objectToJSONBytes(nil, normalObj), Object: meta, }) backend.WriteEvent(ctx, WriteEvent{ Type: resourcepb.WatchEvent_DELETED, Key: &resourcepb.ResourceKey{Namespace: "default", Group: "apps", Resource: "resources", Name: normalName}, PreviousRV: rvAdd, Object: meta, }) // Step 2: add a provisioned resource (should be filtered out from trash) provName := "provisioned-res" provObj := createTestObjectWithName(provName, "default", "data-prov") provMeta, _ := utils.MetaAccessor(provObj) provMeta.SetAnnotation(utils.AnnoKeyManagerKind, "repo") rvProvAdd, _ := backend.WriteEvent(ctx, WriteEvent{ Type: resourcepb.WatchEvent_ADDED, Key: &resourcepb.ResourceKey{Namespace: "default", Group: "apps", Resource: "resources", Name: provName}, Value: objectToJSONBytes(nil, provObj), Object: provMeta, }) backend.WriteEvent(ctx, WriteEvent{ Type: resourcepb.WatchEvent_DELETED, Key: &resourcepb.ResourceKey{Namespace: "default", Group: "apps", Resource: "resources", Name: provName}, PreviousRV: rvProvAdd, Object: provMeta, }) // Step 3: List trash with RV-based, provisioned-filtered behavior listReq := &resourcepb.ListRequest{ Source: resourcepb.ListRequest_TRASH, Options: &resourcepb.ListOptions{Key: &resourcepb.ResourceKey{Namespace: "default", Group: "apps", Resource: "resources"}}, Limit: 10, } type trashItem struct{ name string; rv int64 } var items []trashItem backend.ListHistory(ctx, listReq, func(iter ListIterator) error { for iter.Next() { items = append(items, trashItem{name: iter.Name(), rv: iter.ResourceVersion()}) } return iter.Error() }) fmt.Printf("Trash items (names/RV): %+v\n", items) // Expected outcome (vulnerable version): provisionsed-res would appear in trash results. // Post-fix expectation (this commit): provisioned-res is filtered out; trash contains non-provisioned items only. }

Commit Details

Author: Renato Costa

Date: 2026-03-27 16:49 UTC

Message:

unified-storage: sort by RV desc when listing from trash (#121252) * unified-storage: sort by RV desc when listing from trash * simplify test

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Reasoning:

The change adjusts how trash (deleted items) are listed and explicitly filters out provisioned (non-deleted) items from the trash results. This reduces the risk of exposing non-deleted resources in the trash listing and uses RV-based sorting/pagination to ensure correct visibility order. While the primary intent is correctness and pagination, there is an accompanying security impact: preventing information disclosure of non-deleted resources when listing trash.

Verification Assessment

Vulnerability Type: Information Disclosure

Confidence: MEDIUM

Affected Versions: <=12.4.0

Code Diff

diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index b4140d7e3d887..17e9c147cf467 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -2,6 +2,7 @@ package resource import ( "bytes" + "cmp" "context" "encoding/json" "errors" @@ -10,6 +11,7 @@ import ( "iter" "math/rand/v2" "net/http" + "slices" "sync" "time" @@ -1400,14 +1402,12 @@ func (k *kvStorageBackend) ListHistory(ctx context.Context, req *resourcepb.List key := req.Options.Key // Parse continue token if provided lastSeenRV := int64(0) - lastSeenName := "" if req.NextPageToken != "" { token, err := GetContinueToken(req.NextPageToken) if err != nil { return 0, fmt.Errorf("invalid continue token: %w", err) } lastSeenRV = token.ResourceVersion - lastSeenName = token.Name } // Generate a new resource version for the list @@ -1415,7 +1415,7 @@ func (k *kvStorageBackend) ListHistory(ctx context.Context, req *resourcepb.List // Handle trash differently from regular history if req.Source == resourcepb.ListRequest_TRASH { - return k.processTrashEntries(ctx, req, fn, listRV, lastSeenName) + return k.processTrashEntries(ctx, req, fn, listRV, lastSeenRV) } // Get all history entries by iterating through datastore keys @@ -1471,44 +1471,21 @@ func (k *kvStorageBackend) ListHistory(ctx context.Context, req *resourcepb.List // processTrashEntries handles the special case of listing deleted items (trash). // It streams through keys in ascending order, tracking name groups. For each name, // if the latest event is a delete, it's a trash candidate. +// +// The results are sorted by RV desc: the sorting in this case matters as it's +// the only convenient place to sort results by RV using the datastore before +// doing a BatchGet to fetch the resources. Existing user-facing features (such as +// Restore Dashboards) currently rely on this behaviour. func (k *kvStorageBackend) processTrashEntries( ctx context.Context, req *resourcepb.ListRequest, fn func(ListIterator) error, listRV int64, - lastSeenName string, + lastSeenRV int64, ) (int64, error) { reqKey := req.Options.Key listKey := ListRequestKey{Group: reqKey.Group, Resource: reqKey.Resource, Namespace: reqKey.Namespace, Name: reqKey.Name} - startKey := listKey - if lastSeenName != "" { - // If we are continuing from a previous name (pagination), the start - // key should include the name, but the end key should not. - startKey.Name = lastSeenName - } - - listOptions := ListOptions{ - StartKey: startKey.Prefix(), - EndKey: PrefixRangeEnd(listKey.Prefix()), - Sort: SortOrderAsc, - } - keysIter := func(yield func(DataKey, error) bool) { - for rawKey, err := range k.dataStore.kv.Keys(ctx, dataSection, listOptions) { - if err != nil { - yield(DataKey{}, err) - return - } - dk, err := ParseKey(rawKey) - if err != nil { - yield(DataKey{}, err) - return - } - if !yield(dk, nil) { - return - } - } - } // Stream through keys, tracking name groups to find trash candidates candidates := make([]DataKey, 0, min(defaultListBufferSize, req.Limit+1)) @@ -1519,11 +1496,6 @@ func (k *kvStorageBackend) processTrashEntries( if latestKey == nil { return } - // When resuming from continue token, skip entries for lastSeenName - // (the trash candidate for that name was already yielded) - if lastSeenName != "" && latestKey.Name == lastSeenName { - return - } // Only include if the latest event for this name is a delete (i.e., resource is in trash) if latestKey.Action != DataActionDeleted { return @@ -1535,7 +1507,7 @@ func (k *kvStorageBackend) processTrashEntries( candidates = append(candidates, *latestKey) } - for dk, err := range keysIter { + for dk, err := range k.dataStore.Keys(ctx, listKey, SortOrderAsc) { if err != nil { return 0, err } @@ -1554,6 +1526,15 @@ func (k *kvStorageBackend) processTrashEntries( // Process the final name group processNameGroup() + // Sort candidates by resource version descending so the most recently + // deleted items come first. + slices.SortFunc(candidates, func(a, b DataKey) int { + return cmp.Compare(b.ResourceVersion, a.ResourceVersion) + }) + + // Apply RV-based pagination: skip candidates already seen on previous pages. + candidates = applyPagination(candidates, lastSeenRV) + // Create pull-style iterator from BatchGet next, stop := iter.Pull2(k.dataStore.BatchGet(ctx, candidates)) defer stop() diff --git a/pkg/storage/unified/resource/storage_backend_test.go b/pkg/storage/unified/resource/storage_backend_test.go index bf8d5eeca4dcd..8e7598450a507 100644 --- a/pkg/storage/unified/resource/storage_backend_test.go +++ b/pkg/storage/unified/resource/storage_backend_test.go @@ -1600,101 +1600,96 @@ func TestKvStorageBackend_ListTrash_Success(t *testing.T) { backend := setupTestStorageBackend(t) ctx := context.Background() - // Create a resource - testObj, err := createTestObjectWithName("test-resource", appsNamespace, "test-data") - require.NoError(t, err) + // Helper to create, then delete a resource and return the delete RV. + createAndDelete := func(name string) int64 { + obj, err := createTestObjectWithName(name, appsNamespace, "data-"+name) + require.NoError(t, err) + meta, err := utils.MetaAccessor(obj) + require.NoError(t, err) - metaAccessor, err := utils.MetaAccessor(testObj) - require.NoError(t, err) + addRV, err := backend.WriteEvent(ctx, WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: &resourcepb.ResourceKey{ + Namespace: "default", Group: "apps", Resource: "resources", Name: name, + }, + Value: objectToJSONBytes(t, obj), + Object: meta, + }) + require.NoError(t, err) - writeEvent := WriteEvent{ - Type: resourcepb.WatchEvent_ADDED, - Key: &resourcepb.ResourceKey{ - Namespace: "default", - Group: "apps", - Resource: "resources", - Name: "test-resource", - }, - Value: objectToJSONBytes(t, testObj), - Object: metaAccessor, - PreviousRV: 0, + delRV, err := backend.WriteEvent(ctx, WriteEvent{ + Type: resourcepb.WatchEvent_DELETED, + Key: &resourcepb.ResourceKey{ + Namespace: "default", Group: "apps", Resource: "resources", Name: name, + }, + Value: objectToJSONBytes(t, obj), + Object: meta, + ObjectOld: meta, + PreviousRV: addRV, + }) + require.NoError(t, err) + return delRV } - rv1, err := backend.WriteEvent(ctx, writeEvent) - require.NoError(t, err) + // Create and delete three resources. Names are alphabetical, but delete + // order (and therefore RV order) is: resource-a, resource-b, resource-c. + rvA := createAndDelete("resource-a") + rvB := createAndDelete("resource-b") + rvC := createAndDelete("resource-c") - // Delete the resource - writeEvent.Type = resourcepb.WatchEvent_DELETED - writeEvent.PreviousRV = rv1 - writeEvent.Object = metaAccessor - writeEvent.ObjectOld = metaAccessor - - rv2, err := backend.WriteEvent(ctx, writeEvent) - require.NoError(t, err) - - // Do the same for a provisioned object - provisionedObj, err := createTestObjectWithName("provisioned-obj", appsNamespace, "test-data") + // Also create a provisioned object — it should be filtered out. + provObj, err := createTestObjectWithName("provisioned-obj", appsNamespace, "test-data") require.NoError(t, err) - metaAccessorProvisioned, err := utils.MetaAccessor(provisionedObj) + provMeta, err := utils.MetaAccessor(provObj) require.NoError(t, err) - metaAccessorProvisioned.SetAnnotation(utils.AnnoKeyManagerKind, "repo") + provMeta.SetAnnotation(utils.AnnoKeyManagerKind, "repo") - writeEventProvisioned := WriteEvent{ + provAddRV, err := backend.WriteEvent(ctx, WriteEvent{ Type: resourcepb.WatchEvent_ADDED, Key: &resourcepb.ResourceKey{ - Namespace: "default", - Group: "apps", - Resource: "resources", - Name: "provisioned-obj", + Namespace: "default", Group: "apps", Resource: "resources", Name: "provisioned-obj", }, - Value: objectToJSONBytes(t, provisionedObj), - Object: metaAccessorProvisioned, - PreviousRV: 0, - } - - rv3, err := backend.WriteEvent(ctx, writeEventProvisioned) + Value: objectToJSONBytes(t, provObj), + Object: provMeta, + }) require.NoError(t, err) - - writeEventProvisioned.Type = resourcepb.WatchEvent_DELETED - writeEventProvisioned.PreviousRV = rv3 - writeEventProvisioned.Object = metaAccessorProvisioned - writeEventProvisioned.ObjectOld = metaAccessorProvisioned - _, err = backend.WriteEvent(ctx, writeEventProvisioned) + _, err = backend.WriteEvent(ctx, WriteEvent{ + Type: resourcepb.WatchEvent_DELETED, + Key: &resourcepb.ResourceKey{ + Namespace: "default", Group: "apps", Resource: "resources", Name: "provisioned-obj", + }, + Value: objectToJSONBytes(t, provObj), + Object: provMeta, + ObjectOld: provMeta, + PreviousRV: provAddRV, + }) require.NoError(t, err) - // List the trash (deleted items) + // List all trash items — should be sorted by RV DESC. listReq := &resourcepb.ListRequest{ Options: &resourcepb.ListOptions{ Key: &resourcepb.ResourceKey{ - Namespace: "default", - Group: "apps", - Resource: "resources", - Name: "test-resource", + Namespace: "default", Group: "apps", Resource: "resources", }, }, Source: resourcepb.ListRequest_TRASH, Limit: 10, } - var trashItems []struct { + type trashItem struct { name string resourceVersion int64 - value []byte } + var items []trashItem rv, err := backend.ListHistory(ctx, listReq, func(iter ListIterator) error { for iter.Next() { if err := iter.Error(); err != nil { return err } - trashItems = append(trashItems, struct { - name string - resourceVersion int64 - value []byte - }{ + items = append(items, trashItem{ name: iter.Name(), resourceVersion: iter.ResourceVersion(), - value: iter.Value(), }) } return iter.Error() @@ -1702,12 +1697,16 @@ func TestKvStorageBackend_ListTrash_Success(t *testing.T) { require.NoError(t, err) require.Greater(t, rv, int64(0)) - require.Len(t, trashItems, 1) // Should have the non-provisioned deleted item - - // Verify the trash item - require.Equal(t, "test-resource", trashItems[0].name) - require.Equal(t, rv2, trashItems[0].resourceVersion) - require.Equal(t, objectToJSONBytes(t, testObj), trashItems[0].value) + // Provisioned item is filtered out, leaving 3 non-provisioned items. + require.Len(t, items, 3) + + // Verify RV descending order: resource-c (highest RV) first. + require.Equal(t, "resource-c", items[0].name) + require.Equal(t, rvC, items[0].resourceVersion) + require.Equal(t, "resource-b", items[1].name) + require.Equal(t, rvB, items[1].resourceVersion) + require.Equal(t, "resource-a", items[2].name) + require.Equal(t, rvA, items[2].resourceVersion) } func TestKvStorageBackend_GetResourceStats_Success(t *testing.T) { @@ -3019,8 +3018,10 @@ func TestKvStorageBackend_ListHistory_Behaviour(t *testing.T) { } require.Len(t, allNames, numResources) - slices.Sort(allNames) - slices.Sort(expectedNames) + + // The names collected should be the reverse of `expectedNames` + // (i.e., in RV desc order) + slices.Reverse(expectedNames) require.Equal(t, expectedNames, allNames) }) }
← Back to Alerts View on GitHub →