Information Disclosure
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]