Access Control

HIGH
grafana/grafana
Commit: 99631827e2ab
Affected: < 12.4.0
2026-06-08 11:44 UTC

Description

The commit fixes an access-control reliability bug where in-place mutation of List response identity strips caused by the list helper functions could mutate the cached ListObjectsResponse slice owned by the query cache. When query caching is enabled (CheckQueryCacheEnabled), a List call would downgrade the cached full object idents to bare ids. A subsequent BatchCheck query reads the same cache entry and matches on the full idents (FolderIdent/ResourceIdent). The mutated cache leads to membership lookups failing, potentially hiding resources the user directly has access to (RBAC/authorization decisions become incorrect). The fix makes the strip helpers return a new slice, preventing mutation of the cached response. This is a real vulnerability fix affecting authorization behavior and ensuring correct access decisions. Tests were added: TestStripHelpersDoNotMutateInput (unit) and TestIntegrationListDoesNotPoisonBatchCheckCache (integration).

Proof of Concept

PoC (repro steps) to demonstrate the vulnerability prior to the fix and how the patch resolves it: 1) Prerequisites - Grafana OpenID/RBAC setup with a resource directly granted to a user (e.g., a dashboard) via a role. - Enable the query cache in the authorization service (CheckQueryCacheEnabled = true). - Have a user context that has direct access to a resource but will rely on BatchCheck for authorization decisions. 2) Reproduction steps (pre-fix behavior) - Call List to populate the ListObjectsResponse cache (which includes the full idents like folder:... or resource:...). - The List response path strips the type prefix in place (mutating the slice owned by the cache). With the fix absent, the cached full idents are downgraded to bare ids in the cache. - Call BatchCheck with an identical ListObjects request. The BatchCheck reads the mutated cache (full idents missing the prefix), leading to a mismatch in membership checks (e.g., comparing full idents against cached bare ids). - Expected: The user should have access (allowed for a directly granted resource) but BatchCheck returns not allowed due to the cache corruption. 3) Reproduction steps (post-fix behavior) - The strip helpers now return a new slice instead of mutating input, so the cached ListObjectsResponse is never mutated. - The BatchCheck reads the canonical full idents from the cache and performs membership checks correctly. - Result: The user retains access as expected, and there is no cache-poisoning effect. 4) Minimal Go-style PoC outline (adaptable to the project’s test harness) package main // Pseudo-test harness illustrating the repro flow. func PoC() { // Setup server with cache enabled server := setupOpenFGAServer(nil) setup(nil, server) server.cfg.CacheSettings.CheckQueryCacheEnabled = true ctx := newContextWithNamespace() // 1) List populates the cache _, err := server.List(ctx, &authzv1.ListRequest{ Namespace: namespace, Verb: utils.VerbList, Subject: "user:1", Group: dashboardGroup, Resource: dashboardResource, }) if err != nil { panic(err) } // 2) BatchCheck checks the same resource; should be allowed if user has direct grant res, err := server.BatchCheck(ctx, &authzv1.BatchCheckRequest{ Namespace: namespace, Subject: "user:1", Checks: []*authzv1.BatchCheckItem{{CorrelationId: "c", Verb: utils.VerbGet, Group: dashboardGroup, Resource: dashboardResource, Name: "1", Folder: ""}}, }) if err != nil { panic(err) } if !res.GetResults()["c"].GetAllowed() { panic("expected allowed after List, BatchCheck indicates not allowed due to cache mutation (PoC failed)") } } Note: The repository includes tests that demonstrate the issue and the fix, namely TestStripHelpersDoNotMutateInput and TestIntegrationListDoesNotPoisonBatchCheckCache, which serve as concrete PoC-style validation of the vulnerability and its remediation.

Commit Details

Author: Mihai Turdean

Date: 2026-06-08 09:39 UTC

Message:

Zanzana: Fix list query-cache corruption from in-place ident stripping (#125936) The List response helpers (typedObjects/genericObjects/folderObject) stripped the object-type prefix in place, mutating the *openfgav1.ListObjectsResponse slice that the query cache owns. With CheckQueryCacheEnabled (the default), a List call downgraded the cached full object idents to bare ids, so a later BatchCheck — which reads the same cache entry and matches on the full FolderIdent/ResourceIdent — failed its membership lookups and hid resources the user had direct access to (e.g. a root dashboard granted via a role). Make the strip helpers return a new slice so the cached response is never mutated. List still returns bare ids (the RBAC-backend ListResponse contract); BatchCheck keeps seeing the canonical full idents from the cache. Adds TestStripHelpersDoNotMutateInput (unit) and TestIntegrationListDoesNotPoisonBatchCheckCache (List-then-BatchCheck with the query cache enabled). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Triage Assessment

Vulnerability Type: Access Control

Confidence: HIGH

Reasoning:

The commit fixes a bug where in-place mutation of list response idents polluted the query cache, causing BatchCheck to fail membership/lookups and potentially hide resources a user should have access to. By returning a new slice in the strip helpers, it prevents cache mutation and preserves correct authorization decisions. This directly affects RBAC/authorization behavior and prevents an authorization failure.

Verification Assessment

Vulnerability Type: Access Control

Confidence: HIGH

Affected Versions: < 12.4.0

Code Diff

diff --git a/pkg/services/authz/zanzana/server/server_list.go b/pkg/services/authz/zanzana/server/server_list.go index a04957d9763f5..910bbeeb450c3 100644 --- a/pkg/services/authz/zanzana/server/server_list.go +++ b/pkg/services/authz/zanzana/server/server_list.go @@ -368,23 +368,26 @@ func getRequestHash(req *openfgav1.ListObjectsRequest) (string, error) { func typedObjects(typ string, objects []string) []string { prefix := typ + ":" + out := make([]string, len(objects)) for i := range objects { - objects[i] = strings.TrimPrefix(objects[i], prefix) + out[i] = strings.TrimPrefix(objects[i], prefix) } - return objects + return out } func genericObjects(gr string, objects []string) []string { prefix := common.TypeResourcePrefix + gr + "/" + out := make([]string, len(objects)) for i := range objects { - objects[i] = strings.TrimPrefix(objects[i], prefix) + out[i] = strings.TrimPrefix(objects[i], prefix) } - return objects + return out } func folderObject(objects []string) []string { + out := make([]string, len(objects)) for i := range objects { - objects[i] = strings.TrimPrefix(objects[i], common.TypeFolderPrefix) + out[i] = strings.TrimPrefix(objects[i], common.TypeFolderPrefix) } - return objects + return out } diff --git a/pkg/services/authz/zanzana/server/server_list_test.go b/pkg/services/authz/zanzana/server/server_list_test.go index 0b2e243959959..727372643d39d 100644 --- a/pkg/services/authz/zanzana/server/server_list_test.go +++ b/pkg/services/authz/zanzana/server/server_list_test.go @@ -444,3 +444,59 @@ func TestIntegrationServerListStreamDeadline(t *testing.T) { require.Error(t, err) }) } + +// TestStripHelpersDoNotMutateInput guards the root cause of a cache-poisoning bug: the List +// response strip helpers must return a new slice and never mutate their input, which may be the +// *openfgav1.ListObjectsResponse.Objects slice owned by the query cache. Mutating it in place +// corrupted the cached full object idents that BatchCheck relies on. +func TestStripHelpersDoNotMutateInput(t *testing.T) { + t.Run("typedObjects", func(t *testing.T) { + in := []string{"folder:abc", "folder:def"} + out := typedObjects("folder", in) + require.Equal(t, []string{"folder:abc", "folder:def"}, in, "input must not be mutated") + require.Equal(t, []string{"abc", "def"}, out) + }) + t.Run("genericObjects", func(t *testing.T) { + in := []string{"resource:dashboard.grafana.app/dashboards/x"} + out := genericObjects("dashboard.grafana.app/dashboards", in) + require.Equal(t, []string{"resource:dashboard.grafana.app/dashboards/x"}, in, "input must not be mutated") + require.Equal(t, []string{"x"}, out) + }) + t.Run("folderObject", func(t *testing.T) { + in := []string{"folder:abc", "folder:def"} + out := folderObject(in) + require.Equal(t, []string{"folder:abc", "folder:def"}, in, "input must not be mutated") + require.Equal(t, []string{"abc", "def"}, out) + }) +} + +// TestIntegrationListDoesNotPoisonBatchCheckCache reproduces the end-to-end bug: with the query +// cache enabled, a List call must not corrupt the cached ListObjects response that a subsequent +// BatchCheck (identical request) reads, so a directly-granted resource stays authorized. +func TestIntegrationListDoesNotPoisonBatchCheckCache(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + server := setupOpenFGAServer(t) + setup(t, server) + server.cfg.CacheSettings.CheckQueryCacheEnabled = true + + ctx := newContextWithNamespace() + + // 1. List populates the ListObjects query cache and strips idents for its own response. + _, err := server.List(ctx, &authzv1.ListRequest{ + Namespace: namespace, Verb: utils.VerbList, Subject: "user:1", + Group: dashboardGroup, Resource: dashboardResource, + }) + require.NoError(t, err) + + // 2. BatchCheck issues the identical ListObjects request (cache hit) and must still resolve + // user:1's direct grant on dashboard "1". + res, err := server.BatchCheck(ctx, &authzv1.BatchCheckRequest{ + Namespace: namespace, Subject: "user:1", + Checks: []*authzv1.BatchCheckItem{ + {CorrelationId: "c", Verb: utils.VerbGet, Group: dashboardGroup, Resource: dashboardResource, Name: "1", Folder: ""}, + }, + }) + require.NoError(t, err) + assert.True(t, res.GetResults()["c"].GetAllowed(), "directly-granted dashboard must stay authorized after a prior List") +}
← Back to Alerts View on GitHub →