Information Disclosure

MEDIUM
grafana/grafana
Commit: a331fda2dc61
Affected: <= 12.3.x (pre-fix releases prior to 12.4.0)
2026-07-17 12:16 UTC

Description

The commit adds a server-side check that restricts search facets to only facet-capable fields. Specifically, convertHttpSearchRequestToResourceSearchRequest now rejects facet requests for any field other than the predefined facet field (SEARCH_FIELD_TAGS), returning a BadRequest when an unsupported field is used. The Bleve-backed search path also maps facet request names to actual index fields and rejects unknown fields with a BadRequest, preventing facet queries on internal or non-facet fields. This mitigates information disclosure risks where an attacker could use the search API to enumerate or infer internal field mappings (e.g., labels.region, folder, etc.) by requesting facets on non-public fields. The fix thus closes a potential exposure surface by ensuring only known facet-capable fields (tags) can be used in faceting, and by validating facets against mapped, supported fields before constructing the Bleve query.

Proof of Concept

PoC (conceptual, executable steps would require a Grafana instance with the vulnerable version): Pre-fix exploit (demonstrates potential information disclosure through facets): - Objective: Use the search API to facet on an internal/non-facet field (e.g., region or folder) and observe the exposed facet results. - Request (illustrative): curl -sS -H "Authorization: Bearer <TOKEN>" 'https://grafana.example.com/api/dashboards/search?facet=region' - Expected problematic response (illustrative): HTTP/1.1 200 OK { "facet": { "region": { "field": "labels.region", "total": 3, "terms": [ {"term": "west", "count": 2}, {"term": "east", "count": 1} ] } } } - Impact: An attacker can enumerate values and counts of internal fields exposed via facets, aiding information disclosure about dashboards, folders, or other internal structures. Post-fix behavior (after the fix in 12.4.0): - The same request yields a BadRequest because facet requests are restricted to supported fields (tags). - Request: curl -sS -H "Authorization: Bearer <TOKEN>" 'https://grafana.example.com/api/dashboards/search?facet=region' - Response (illustrative): HTTP/1.1 400 Bad Request { "error": "faceting is not supported for field \"region\"" } - This proves the vulnerability is mitigated by restricting facet fields to supported, facet-capable fields only.

Commit Details

Author: Peter Štibraný

Date: 2026-07-17 11:09 UTC

Message:

Search: restrict Bleve facets to facet-capable fields (#128670) * Search: restrict Bleve facets to facet-capable fields * Search: fix misplaced test comment

Triage Assessment

Vulnerability Type: Information Disclosure

Confidence: MEDIUM

Reasoning:

The commit adds a validation that only facet-capable fields can be used for search facets and rejects unsupported fields with a Bad Request. This prevents potential exposure of internal or non-facet fields through the search API and enforces access to only allowed fields, which is a security and information disclosure concern.

Verification Assessment

Vulnerability Type: Information Disclosure

Confidence: MEDIUM

Affected Versions: <= 12.3.x (pre-fix releases prior to 12.4.0)

Code Diff

diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index b17f6624e167..477a6f997510 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -714,6 +714,9 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use } searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet) for _, v := range facets { + if v != resource.SEARCH_FIELD_TAGS { + return nil, apierrors.NewBadRequest(fmt.Sprintf("faceting is not supported for field %q", v)) + } searchRequest.Facet[v] = &resourcepb.ResourceSearchRequest_Facet{ Field: v, Limit: int64(facetLimit), diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 683487aea4b5..7b8b622446cb 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -13,6 +13,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/selection" "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" @@ -981,7 +982,7 @@ func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) { }, }, "facet fields": { - queryString: "facet=tags&facet=folder", + queryString: "facet=tags", expected: &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{Key: dashboardKey}, Query: "", @@ -991,8 +992,7 @@ func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) { Explain: false, Fields: defaultFields, Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ - "tags": {Field: "tags", Limit: 50}, - "folder": {Field: "folder", Limit: 50}, + "tags": {Field: "tags", Limit: 50}, }, Federated: []*resourcepb.ResourceKey{folderKey}, }, @@ -1238,6 +1238,20 @@ func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) { }) } + t.Run("rejects unsupported facet fields", func(t *testing.T) { + queryParams, err := url.ParseQuery("facet=folder") + require.NoError(t, err) + + result, err := convertHttpSearchRequestToResourceSearchRequest(queryParams, testUser, func(dashboardaccess.PermissionType) ([]string, error) { + return nil, nil + }) + + require.Nil(t, result) + require.Error(t, err) + assert.True(t, apierrors.IsBadRequest(err)) + assert.ErrorContains(t, err, `faceting is not supported for field "folder"`) + }) + t.Run("adds k6 exclusion for non-service accounts", func(t *testing.T) { queryParams, err := url.ParseQuery("") require.NoError(t, err) diff --git a/pkg/storage/unified/resource/search_field_test.go b/pkg/storage/unified/resource/search_field_test.go index bfdda7f1fd8b..04a6352b9c97 100644 --- a/pkg/storage/unified/resource/search_field_test.go +++ b/pkg/storage/unified/resource/search_field_test.go @@ -232,8 +232,8 @@ func TestMapProvider_IndexAffectingHash_GoldenHash(t *testing.T) { }, }, nil) - // The standard name field has sort capability so Bleve can use it as a stable pagination tie-breaker. - const expected = "3484142f1ce8094a4659bb0275fe91e4d68f0ce6fa1ed24a17dd81615a8a76d1" + // Standard tags declare facet capability so Bleve facets use their keyword-analyzed mapping. + const expected = "0f114ef8fa64163466eb9d3477163cfe964b5a626c37279d5e60a2586c18a064" assert.Equal(t, expected, p.IndexAffectingHash(group, resource), "canonical hash drifted. If json.Marshal output changed (Go release), update the literal; otherwise a code change shifted the canonical form.") } diff --git a/pkg/storage/unified/resource/standard_search_fields.go b/pkg/storage/unified/resource/standard_search_fields.go index 2614ee7d09cc..182a31cdb7ce 100644 --- a/pkg/storage/unified/resource/standard_search_fields.go +++ b/pkg/storage/unified/resource/standard_search_fields.go @@ -50,7 +50,7 @@ func StandardSearchFieldDefinitions() []SearchFieldDefinition { Name: SEARCH_FIELD_TAGS, Type: SearchFieldTypeString, Array: true, - Capabilities: []SearchCapability{SearchCapabilityFilter, SearchCapabilityRetrieve}, + Capabilities: []SearchCapability{SearchCapabilityFilter, SearchCapabilityFacet, SearchCapabilityRetrieve}, Description: "Unique tags.", }, { diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index fbee71a5fdba..4ac213e801ca 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -822,6 +822,7 @@ func (b *bleveBackend) BuildIndex( } idx := b.newBleveIndex(key, prepared.index, prepared.indexStorage, fields, allFields, standardSearchFields, updater, b.log.New("namespace", key.Namespace, "group", key.Group, "resource", key.Resource)) + idx.facetFieldByRequestName = facetFieldsForMapping(searchFieldsProvider, key.Group, key.Resource) if prepared.source.needsBuild() { // Type-convert so buildIndexFromScratch can call updateResourceVersion after the builder returns. @@ -1235,6 +1236,7 @@ func (b *bleveBackend) promoteBuildIndexToFile( } promoted := b.newBleveIndex(key, fileIndex, indexStorageFile, fields, allFields, standardSearchFields, updater, delegate.logger) + promoted.facetFieldByRequestName = delegate.facetFieldByRequestName promoted.resourceVersion.Store(delegate.resourceVersion.Load()) cleanup = false @@ -1601,6 +1603,9 @@ type bleveIndex struct { standard resource.SearchableDocumentFields fields resource.SearchableDocumentFields + // facetFieldByRequestName maps ResourceSearchRequest facet field names to + // keyword-analyzed Bleve index field names. + facetFieldByRequestName map[string]string indexStorage string // memory or file, used when updating metrics @@ -2111,6 +2116,11 @@ func (b *bleveIndex) Search( // parse the facet fields for k, v := range res.Facets { f := newResponseFacet(v) + // Bleve reports the physical keyword-variant field. Keep that internal + // detail out of the API response. + if requested, ok := req.Facet[k]; ok { + f.Field = requested.Field + } if response.Facet == nil { response.Facet = make(map[string]*resourcepb.ResourceSearchResponse_Facet) } @@ -2198,8 +2208,12 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R defer span.End() facets := bleve.FacetsRequest{} - for _, f := range req.Facet { - facets[f.Field] = bleve.NewFacetRequest(f.Field, int(f.Limit)) + for name, facet := range req.Facet { + field, ok := b.facetFieldByRequestName[facet.Field] + if !ok { + return nil, resource.NewBadRequestError(fmt.Sprintf("field %q does not support faceting", facet.Field)) + } + facets[name] = bleve.NewFacetRequest(field, int(facet.Limit)) } // Convert resource-specific fields to bleve fields. Any field declared @@ -2281,13 +2295,6 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R }) } - for k, v := range req.Facet { - if searchrequest.Facets == nil { - searchrequest.Facets = make(bleve.FacetsRequest) - } - searchrequest.Facets[k] = bleve.NewFacetRequest(v.Field, int(v.Limit)) - } - // Add the sort fields sorting := getSortFields(req, b.fields) searchrequest.SortBy(sorting) diff --git a/pkg/storage/unified/search/bleve_mappings.go b/pkg/storage/unified/search/bleve_mappings.go index b861a6ea3f69..3b69eb1f4458 100644 --- a/pkg/storage/unified/search/bleve_mappings.go +++ b/pkg/storage/unified/search/bleve_mappings.go @@ -24,6 +24,32 @@ func fieldDefinitionsForMapping(provider resource.SearchFieldsProvider, group, k return provider.Fields(schema.GroupVersionResource{Group: group, Resource: kindResource}) } +// facetFieldsForMapping returns the logical facet field names accepted by the +// search API and the keyword-analyzed bleve fields that back them. Dynamic +// fields are deliberately absent because they have no facet capability. +func facetFieldsForMapping(provider resource.SearchFieldsProvider, group, kindResource string) map[string]string { + fields := make(map[string]string) + add := func(def resource.SearchFieldDefinition, prefix string) { + if !def.HasCapability(resource.SearchCapabilityFacet) { + return + } + logicalName := prefix + def.Name + fields[logicalName] = prefix + keywordVariantName(def.Name, def.HasCapability(resource.SearchCapabilityText)) + } + + for _, def := range resource.StandardSearchFieldDefinitions() { + add(def, "") + } + for _, def := range fieldDefinitionsForMapping(provider, group, kindResource) { + add(def, resource.SEARCH_FIELD_PREFIX) + // Requests accept per-kind fields with or without the internal fields. prefix. + if physicalName, ok := fields[resource.SEARCH_FIELD_PREFIX+def.Name]; ok { + fields[def.Name] = physicalName + } + } + return fields +} + // addCapabilityFieldMappings adds bleve field mappings to parent for a single // declared search field. The field is placed under parent using def.Name as // the local name; this helper does not add any sub-document prefix (callers diff --git a/pkg/storage/unified/search/bleve_mappings_internal_test.go b/pkg/storage/unified/search/bleve_mappings_internal_test.go index 4d0064215349..de497dc0e81f 100644 --- a/pkg/storage/unified/search/bleve_mappings_internal_test.go +++ b/pkg/storage/unified/search/bleve_mappings_internal_test.go @@ -11,6 +11,7 @@ import ( "github.com/blevesearch/bleve/v2/mapping" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -211,6 +212,36 @@ func TestAddCapabilityFieldMappings_FacetOnly(t *testing.T) { assert.False(t, m.Store) } +func TestFacetFieldsForMapping(t *testing.T) { + gvr := schema.GroupVersionResource{Group: "example.test", Version: "v1", Resource: "widgets"} + provider := resource.NewMapProvider(map[schema.GroupVersionResource][]resource.SearchFieldDefinition{ + gvr: { + { + Name: "summary", + Type: resource.SearchFieldTypeString, + Capabilities: []resource.SearchCapability{ + resource.SearchCapabilityText, + resource.SearchCapabilityFacet, + }, + }, + { + Name: "category", + Type: resource.SearchFieldTypeString, + Capabilities: []resource.SearchCapability{resource.SearchCapabilityFacet}, + }, + }, + }, nil) + + fields := facetFieldsForMapping(provider, gvr.Group, gvr.Resource) + assert.Equal(t, resource.SEARCH_FIELD_TAGS, fields[resource.SEARCH_FIELD_TAGS]) + assert.Equal(t, resource.SEARCH_FIELD_MANAGED_BY, fields[resource.SEARCH_FIELD_MANAGED_BY]) + assert.Equal(t, "fields.summary_keyword", fields["summary"]) + assert.Equal(t, "fields.summary_keyword", fields["fields.summary"]) + assert.Equal(t, "fields.category", fields["category"]) + assert.NotContains(t, fields, resource.SEARCH_FIELD_FOLDER) + assert.NotContains(t, fields, "labels.region") +} + func TestAddCapabilityFieldMappings_RetrieveOnly_StoreOnly(t *testing.T) { // With no dynamic fallback, a retrieve-only field must be stored explicitly. t.Run("int64", func(t *testing.T) { diff --git a/pkg/storage/unified/search/bleve_mappings_test.go b/pkg/storage/unified/search/bleve_mappings_test.go index 5ae5330e9097..23cc2b5f0387 100644 --- a/pkg/storage/unified/search/bleve_mappings_test.go +++ b/pkg/storage/unified/search/bleve_mappings_test.go @@ -135,6 +135,29 @@ func TestTermVectorsAndFreqNorm(t *testing.T) { } } +func TestTagsFacetPreservesMultiWordValues(t *testing.T) { + mappings, err := search.GetBleveMappings(nil, "", "", nil) + require.NoError(t, err) + + idx, err := bleve.NewMemOnly(mappings) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, idx.Close()) }) + + doc := resource.IndexableDocument{Tags: []string{"US West"}} + require.NoError(t, idx.Index("a", doc)) + + req := bleve.NewSearchRequest(bleve.NewMatchAllQuery()) + req.Size = 0 + req.AddFacet("tags", bleve.NewFacetRequest(resource.SEARCH_FIELD_TAGS, 10)) + result, err := idx.Search(req) + require.NoError(t, err) + + terms := result.Facets["tags"].Terms.Terms() + require.Len(t, terms, 1) + assert.Equal(t, "US West", terms[0].Term) + assert.Equal(t, 1, terms[0].Count) +} + // TestStandardCreatedUpdatedAreNumeric guards that the int64 standard fields // created and updated are stored as numbers and returned on retrieve: with a // keyword mapping bleve dropped the numeric value entirely. They are diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index df323bbe6a84..d4055971491f 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -684,8 +684,8 @@ func testBleveBackend(t *testing.T, backend *bleveBackend) { {Field: "title", Desc: false}, }, Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ - "region": { - Field: "labels.region", + "tags": { + Field: resource.SEARCH_FIELD_TAGS, Limit: 100, }, }, @@ -710,23 +710,21 @@ func testBleveBackend(t *testing.T, backend *bleveBackend) { resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-federated.json"), rsp.Results) - facet, ok := rsp.Facet["region"] + facet, ok := rsp.Facet["tags"] require.True(t, ok) disp, err := json.MarshalIndent(facet, "", " ") require.NoError(t, err) - // fmt.Printf("%s\n", disp) - // NOTE, the west values come from *both* dashboards and folders require.JSONEq(t, `{ - "field": "labels.region", - "total": 3, + "field": "tags", + "total": 4, "missing": 2, "terms": [ { - "term": "west", - "count": 2 + "term": "aa", + "count": 3 }, { - "term": "east", + "term": "bb", "count": 1 } ] @@ -748,8 +746,8 @@ func testBleveBackend(t *testing.T, backend *bleveBackend) { {Field: "title", Desc: false}, }, Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ - "region": { - Field: "labels.region", + "tags": { + Field: resource.SEARCH_FIELD_TAGS, Limit: 100, }, }, @@ -777,8 +775,8 @@ func testBleveBackend(t *testing.T, backend *bleveBackend) { {Field: "title", Desc: false}, }, Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ - "region": { - Field: "labels.region", + "tags": { + Field: resource.SEARCH_FIELD_TAGS, Limit: 100, }, }, @@ -805,8 +803,8 @@ func testBleveBackend(t *testing.T, backend *bleveBackend) { {Field: "title", Desc: false}, }, Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ - "region": { - Field: "labels.region", + "tags": { + Field: resource.SEARCH_FIELD_TAGS, Limit: 100, }, }, @@ -871,7 +869,10 @@ func TestGetSortFields(t *testing.T) { } func TestBleveSearchRequestDefaultSortIncludesNameTieBreaker(t *testing.T) { - idx := &blev ... [truncated]
← Back to Alerts View on GitHub →