Information disclosure / Access-control hardening

HIGH
grafana/grafana
Commit: dfaf0a740fc4
Affected: Grafana 12.4.0 and earlier
2026-04-15 18:25 UTC

Description

This commit implements an information-disclosure hardening in the unified search API by filtering out the K6 technical folder for non-service accounts. Previously, regular users could observe internal K6 RBAC artifacts (the K6 folder) in search results, potentially leaking internal structure and permissions. The change adds a NotIn filter on the SEARCH_FIELD_NAME for the K6FolderUID when the requester is not a service account, and updates tests accordingly. This appears to be a genuine security vulnerability fix aimed at reducing unintended information disclosure via search results.

Proof of Concept

PoC (proof of concept): Prerequisites: - A Grafana instance with a K6 technical folder present in the search index (folder UID: k6-app). - A user account that is not a service account and has permission to perform unified searches. - Access token for this non-service-account user. Steps to reproduce (pre-fix behavior): 1) Authenticate as a regular user and obtain an API token. 2) Run a search that would include the K6 folder (e.g., query=*): curl -s -H "Authorization: Bearer <USER_TOKEN>" 'https://grafana.example.com/api/search?query=*' 3) Inspect the JSON response and look for an entry that corresponds to the K6 technical folder (e.g., a row with something like title/name mapping to k6-app or a resource with UID k6-app). 4) Observe that the K6 folder (internal RBAC artifact) is exposed in the search results, constituting an information-disclosure vulnerability. Steps to verify the fix (post-fix behavior): 1) Authenticate as the same non-service-account user and run the same search again. 2) The response should no longer include the K6 folder entry in the results. Notes: - The code path shows the filter is applied only for non-service accounts, using NotIn on the search field with the K6FolderUID (k6-app). This PoC demonstrates the presence (pre-fix) and absence (post-fix) of that entry in the search results.

Commit Details

Author: Andrej Ocenas

Date: 2026-04-15 18:11 UTC

Message:

Search API: Filter out k6 technical folder in unified search (#122674) * Filter out k6 technical folder * Remove unused function * Update snapshot tests

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: HIGH

Reasoning:

The change adds a filter to prevent the K6 technical folder from appearing in unified search results for non-service accounts, reducing inadvertent information disclosure of internal RBAC artifacts. This is an access-control / information-disclosure hardening.

Verification Assessment

Vulnerability Type: Information disclosure / Access-control hardening

Confidence: HIGH

Affected Versions: Grafana 12.4.0 and earlier

Code Diff

diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 4e3bad201547a..965f1146c3a43 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -12,9 +12,11 @@ import ( "strconv" "strings" + claims "github.com/grafana/authlib/types" "go.opentelemetry.io/otel/trace" apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" @@ -23,6 +25,7 @@ import ( folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" @@ -580,6 +583,16 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use }) } + // K6 creates a technical folder for some rbac handling that should not be visible to normal user. + // The legacy search backend ignores NotIn on name, but we should be mostly in mode 4+ now. + if !user.IsIdentityType(claims.TypeServiceAccount) { + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ + Key: resource.SEARCH_FIELD_NAME, + Operator: string(selection.NotIn), + Values: []string{accesscontrol.K6FolderUID}, + }) + } + if searchRequest.Query == "*" { searchRequest.Query = "" // will match everything } else if searchRequest.Query != "" { diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 9a1c153fab7ce..7f38ff3cc7d89 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -10,11 +10,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" + "k8s.io/apimachinery/pkg/selection" "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -104,6 +106,43 @@ func TestSearchHandler(t *testing.T) { assert.Equal(t, mockResults[2].Value, p.Hits[0].Title) assert.Equal(t, mockResults[1].Value, p.Hits[3].Title) }) + + t.Run("filters k6 technical folder from results for non-service accounts", func(t *testing.T) { + mockResponse := &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + {Name: resource.SEARCH_FIELD_TITLE}, + }, + Rows: []*resourcepb.ResourceTableRow{}, + }, + } + + mockClient := &MockClient{ + MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse}, + } + + searchHandler := SearchHandler{ + log: log.New("test", "test"), + client: mockClient, + tracer: tracing.NewNoopTracerService(), + features: featuremgmt.WithFeatures(), + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search", nil) + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + resp := rr.Result() + defer func() { + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + }() + + assert.Equal(t, string(selection.NotIn), mockClient.MockCalls[0].Options.Fields[0].Operator) + assert.Equal(t, []string{"k6-app"}, mockClient.MockCalls[0].Options.Fields[0].Values) + }) } func TestSearchHandlerSharedDashboards(t *testing.T) { @@ -359,7 +398,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { // first call gets all dashboards user has permission for firstCall := mockClient.MockCalls[0] - assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"}) + assert.Equal(t, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"}, firstCall.Options.Fields[0].Values) // verify federated field is set to include folders assert.NotNil(t, firstCall.Federated) assert.Equal(t, 1, len(firstCall.Federated)) @@ -367,11 +406,11 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { assert.Equal(t, "folders", firstCall.Federated[0].Resource) // second call gets folders associated with the previous dashboards secondCall := mockClient.MockCalls[1] - assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"}) + assert.Equal(t, []string{"privatefolder", "publicfolder"}, secondCall.Options.Fields[0].Values) // lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have // permission to read thirdCall := mockClient.MockCalls[2] - assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"}) + assert.Equal(t, []string{"dashboardinprivatefolder", "sharedfolder"}, thirdCall.Options.Fields[1].Values) resp := rr.Result() defer func() { @@ -499,7 +538,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { thirdCall := mockClient.MockCalls[2] assert.Equal(t, int64(dashboardaccess.PERMISSION_EDIT), thirdCall.Permission) - assert.Equal(t, []string{"dashboardinprivatefolder"}, thirdCall.Options.Fields[0].Values) + assert.Equal(t, []string{"dashboardinprivatefolder"}, thirdCall.Options.Fields[1].Values) resp := rr.Result() defer func() { @@ -517,8 +556,9 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) { testUser := &user.SignedInUser{ - Namespace: "test-namespace", - OrgID: 1, + Namespace: "test-namespace", + OrgID: 1, + IsServiceAccount: true, } dashboardKey := &resourcepb.ResourceKey{ @@ -969,6 +1009,42 @@ func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) { assert.Equal(t, tt.expected, result) }) } + + t.Run("adds k6 exclusion for non-service accounts", func(t *testing.T) { + queryParams, err := url.ParseQuery("") + require.NoError(t, err) + + result, err := convertHttpSearchRequestToResourceSearchRequest(queryParams, &user.SignedInUser{ + Namespace: "test-namespace", + OrgID: 1, + }, func(requestedPermission dashboardaccess.PermissionType) ([]string, error) { + return nil, nil + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Options.Fields, 1) + assert.Equal(t, resource.SEARCH_FIELD_NAME, result.Options.Fields[0].Key) + assert.Equal(t, string(selection.NotIn), result.Options.Fields[0].Operator) + assert.Equal(t, []string{accesscontrol.K6FolderUID}, result.Options.Fields[0].Values) + }) + + t.Run("keeps k6 folder visible for service accounts", func(t *testing.T) { + queryParams, err := url.ParseQuery("") + require.NoError(t, err) + + result, err := convertHttpSearchRequestToResourceSearchRequest(queryParams, &user.SignedInUser{ + Namespace: "test-namespace", + OrgID: 1, + IsServiceAccount: true, + }, func(requestedPermission dashboardaccess.PermissionType) ([]string, error) { + return nil, nil + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Empty(t, result.Options.Fields) + }) } // MockClient implements the ResourceIndexClient interface for testing diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json index ebab0953d2937..7b9243e3ec55b 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json @@ -10,7 +10,7 @@ "panel-tests", "graph-ng" ], - "score": 0.122 + "score": 0.15 }, { "resource": "dashboards", @@ -21,8 +21,8 @@ "panel-tests", "graph-ng" ], - "score": 0.116 + "score": 0.144 } ], - "maxScore": 0.122 + "maxScore": 0.15 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json b/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json index f446ad000cb6d..969b2f5c74ea6 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json @@ -10,8 +10,8 @@ "panel-tests", "graph-ng" ], - "score": 0.184 + "score": 0.211 } ], - "maxScore": 0.184 + "maxScore": 0.211 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json b/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json index a758e4dfc06bd..441cc63e8160b 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json @@ -14,5 +14,5 @@ } } ], - "maxScore": 1.125 + "maxScore": 1.417 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json b/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json index d073d6dc4b9e4..f7aa9f4418a66 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json @@ -10,8 +10,8 @@ "panel-tests", "graph-ng" ], - "score": 0.225 + "score": 0.253 } ], - "maxScore": 0.225 + "maxScore": 0.253 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json b/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json index d073d6dc4b9e4..f7aa9f4418a66 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json @@ -10,8 +10,8 @@ "panel-tests", "graph-ng" ], - "score": 0.225 + "score": 0.253 } ], - "maxScore": 0.225 + "maxScore": 0.253 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t06-panel-title-orange.json b/pkg/tests/apis/dashboard/testdata/searchV0/t06-panel-title-orange.json index acb9f65ee3da3..b834af9ab9a1b 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t06-panel-title-orange.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t06-panel-title-orange.json @@ -10,7 +10,7 @@ "panel-tests", "graph-ng" ], - "score": 0.102, + "score": 0.128, "explain": { "children": [ { @@ -21,68 +21,110 @@ "children": [ { "children": [ - { - "message": "boost", - "value": 5 - }, - { - "message": "idf(docFreq=1, maxDocs=17)", - "value": 2.485 - }, - { - "message": "queryNorm", - "value": 0.026 - } - ], - "message": "queryWeight(fields.panel_title:orange^5.000000), product of:", - "value": 0.321 - }, - { - "children": [ - { - "message": "tf(termFreq(fields.panel_title:orange)=3", - "value": 1.732 - }, { "children": [ { - "message": "fieldNorm(field=fields.panel_title), b=0.750000, fieldLength=38.000002, avgFieldLength=17.000000)", - "value": 1.926 + "message": "boost", + "value": 5 + }, + { + "message": "idf(docFreq=1, maxDocs=17)", + "value": 2.485 + }, + { + "message": "queryNorm", + "value": 0.026 } ], - "message": "saturation(term:), k1=1.200000/(tf=1.732051 + k1*fieldNorm=1.926471))", - "value": 0.297 + "message": "queryWeight(fields.panel_title:orange^5.000000), product of:", + "value": 0.32 }, { - "message": "idf(docFreq=1, maxDocs=17)", - "value": 2.485 + "children": [ + { + "message": "tf(termFreq(fields.panel_title:orange)=3", + "value": 1.732 + }, + { + "children": [ + { + "message": "fieldNorm(field=fields.panel_title), b=0.750000, fieldLength=38.000002, avgFieldLength=17.000000)", + "value": 1.926 + } + ], + "message": "saturation(term:), k1=1.200000/(tf=1.732051 + k1*fieldNorm=1.926471))", + "value": 0.297 + }, + { + "message": "idf(docFreq=1, maxDocs=17)", + "value": 2.485 + } + ], + "message": "fieldWeight(fields.panel_title:orange in \u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0001), as per bm25 model, product of:", + "value": 1.277 } ... [truncated]
← Back to Alerts View on GitHub →