Denial of Service (panic/crash via invalid runtime manifest)
Description
The commit changes the runtime handling of search-field manifest ingestion from panicking on invalid declarations to returning an error. Previously, loading a bad manifest at runtime could crash the service, potentially enabling a denial-of-service if an attacker could supply a malicious manifest. The fix introduces error-returning constructors (newMapProvider/newManifestBackedProvider) and propagates these errors up to the caller (e.g., NewSearchOptions/SearchFieldProviders), with tests ensuring errors are surfaced instead of panics. This hardens the system against DoS via invalid manifests.
Proof of Concept
PoC (Go) demonstrating the vulnerable behavior prior to this fix:
Note: This snippet uses Grafana's internal types in their repo paths. It constructs a deliberately invalid manifest that would previously trigger a panic when loaded at runtime, illustrating a denial-of-service vector. In the fixed version, this call returns an error instead of panicking.
package main
import (
app "github.com/grafana/grafana/pkg/app"
res "github.com/grafana/grafana/pkg/storage/unified/resource"
"fmt"
)
func main() {
// Construct an invalid manifest declaration (as used in tests for regression)
invalid := app.Manifest{ManifestData: &app.ManifestData{
Group: "widgets.example.test",
Versions: []app.ManifestVersion{{
Name: "v1",
Kinds: []app.ManifestVersionKind{{
Kind: "Widget",
Plural: "widgets",
SearchFields: []app.ManifestVersionKindSearchField{{
Name: "size",
Path: "spec.size",
Type: "int64",
Capabilities: []string{"text"},
}},
}},
}},
}}
// This call would panic in vulnerable versions; in this patch it should return an error
_, err := res.SearchFieldProviders([]app.Manifest{invalid})
if err != nil {
fmt.Println("error:", err)
} else {
fmt.Println("no error (unexpected in vulnerable code)")
}
}
Commit Details
Author: Peter Štibraný
Date: 2026-07-15 11:03 UTC
Message:
Storage: make search-fields manifest ingestion error-returning (#128464)
The map- and manifest-backed search-fields providers validated their input
and panicked on an invalid definition. That is right for the in-tree,
build-time manifests we have today, where a bad declaration is a programmer
error. It will be wrong once manifests are loaded from a live source at
runtime, where a single bad manifest would take down search indexing for
every kind.
Split each constructor into a panicking wrapper (unchanged for the static
builder callers) and an error-returning core, and make SearchFieldProviders
return an error that NewSearchOptions propagates. No behaviour change today:
the in-tree manifests are always valid, so the error never fires yet.
Triage Assessment
Vulnerability Type: Denial of Service (crash) via invalid input
Confidence: HIGH
Reasoning:
The commit changes runtime handling of invalid search field definitions from panicking to returning errors. Previously, loading bad manifests at runtime could crash the service, potentially enabling denial-of-service by an attacker-controlled manifest. The changes add input validation at runtime and tests ensuring errors are surfaced instead of panics, which is a security-hardening measure.
Verification Assessment
Vulnerability Type: Denial of Service (panic/crash via invalid runtime manifest)
Confidence: HIGH
Affected Versions: <= 12.3.x (pre-12.4.0)
Code Diff
diff --git a/pkg/storage/unified/resource/search_field.go b/pkg/storage/unified/resource/search_field.go
index a0073ef248f6..5b408f862818 100644
--- a/pkg/storage/unified/resource/search_field.go
+++ b/pkg/storage/unified/resource/search_field.go
@@ -259,11 +259,19 @@ type mapProvider struct {
// maps. Both arguments may be nil. The provider takes ownership of the maps;
// callers must not mutate them after the call.
//
-// Panics if any SearchFieldDefinition violates validateSearchFieldDefinitions
-// (e.g. SearchCapabilitySort declared on a numeric or boolean type). Such a
-// declaration is a programmer error in static configuration; the process
-// cannot serve correct results with an invalid mapping.
+// Panics on an invalid definition: in static build-time config that is a
+// programmer error. Runtime callers use newMapProvider instead.
func NewMapProvider(fields map[schema.GroupVersionResource][]SearchFieldDefinition, preferredVersions map[schema.GroupResource]string) SearchFieldsProvider {
+ p, err := newMapProvider(fields, preferredVersions)
+ if err != nil {
+ panic(err.Error())
+ }
+ return p
+}
+
+// newMapProvider is NewMapProvider's error-returning core, so a caller
+// ingesting definitions at runtime can reject a bad set instead of crashing.
+func newMapProvider(fields map[schema.GroupVersionResource][]SearchFieldDefinition, preferredVersions map[schema.GroupResource]string) (SearchFieldsProvider, error) {
if fields == nil {
fields = map[schema.GroupVersionResource][]SearchFieldDefinition{}
}
@@ -272,16 +280,16 @@ func NewMapProvider(fields map[schema.GroupVersionResource][]SearchFieldDefiniti
}
for gvr, sfds := range fields {
if err := validateSearchFieldDefinitions(sfds); err != nil {
- panic("invalid SearchFieldDefinitions for " + gvr.String() + ": " + err.Error())
+ return nil, fmt.Errorf("invalid SearchFieldDefinitions for %s: %w", gvr.String(), err)
}
}
if err := validateCrossVersionConsistency(fields); err != nil {
- panic("inconsistent SearchFieldDefinitions across versions: " + err.Error())
+ return nil, fmt.Errorf("inconsistent SearchFieldDefinitions across versions: %w", err)
}
return &mapProvider{
fields: fields,
preferredVersion: preferredVersions,
- }
+ }, nil
}
// mappingAttributes is the bleve-mapping-affecting projection of a
diff --git a/pkg/storage/unified/resource/search_field_manifest.go b/pkg/storage/unified/resource/search_field_manifest.go
index b2463c52f732..59449a929d64 100644
--- a/pkg/storage/unified/resource/search_field_manifest.go
+++ b/pkg/storage/unified/resource/search_field_manifest.go
@@ -15,7 +15,20 @@ import (
//
// CopyFromStandard has no manifest counterpart and is never set from a
// manifest. A kind that needs it keeps a builder-side provider instead.
+//
+// Panics on an invalid declaration, like NewMapProvider. Runtime callers use
+// newManifestBackedProvider instead.
func NewManifestBackedProvider(manifests []app.Manifest) SearchFieldsProvider {
+ p, err := newManifestBackedProvider(manifests)
+ if err != nil {
+ panic(err.Error())
+ }
+ return p
+}
+
+// newManifestBackedProvider is NewManifestBackedProvider's error-returning
+// core, so a runtime manifest source can reject a bad set instead of crashing.
+func newManifestBackedProvider(manifests []app.Manifest) (SearchFieldsProvider, error) {
fields := map[schema.GroupVersionResource][]SearchFieldDefinition{}
preferred := map[schema.GroupResource]string{}
@@ -47,7 +60,7 @@ func NewManifestBackedProvider(manifests []app.Manifest) SearchFieldsProvider {
}
}
}
- return NewMapProvider(fields, preferred)
+ return newMapProvider(fields, preferred)
}
// manifestResourceName returns the lower-cased resource (plural) name for a
@@ -109,18 +122,21 @@ func manifestDeclaredKindKeys(manifests []app.Manifest) map[LowerGroupResource]b
// drives bleve mappings. Every kind that declares search fields in its manifest
// is mapped to a single manifest-backed provider; each entry queries it for its
// own (group, resource).
-func SearchFieldProviders(manifests []app.Manifest) map[LowerGroupResource]SearchFieldsProvider {
+func SearchFieldProviders(manifests []app.Manifest) (map[LowerGroupResource]SearchFieldsProvider, error) {
declared := manifestDeclaredKindKeys(manifests)
out := make(map[LowerGroupResource]SearchFieldsProvider, len(declared))
if len(declared) == 0 {
- return out
+ return out, nil
}
// A single manifest-backed provider covers every declared kind; each map
// entry queries it for its own (group, resource).
- manifestProvider := NewManifestBackedProvider(manifests)
+ manifestProvider, err := newManifestBackedProvider(manifests)
+ if err != nil {
+ return nil, err
+ }
for key := range declared {
out[key] = manifestProvider
}
- return out
+ return out, nil
}
diff --git a/pkg/storage/unified/resource/search_field_manifest_test.go b/pkg/storage/unified/resource/search_field_manifest_test.go
index 93409a2bb0ed..fdb6f2255d54 100644
--- a/pkg/storage/unified/resource/search_field_manifest_test.go
+++ b/pkg/storage/unified/resource/search_field_manifest_test.go
@@ -94,7 +94,8 @@ func TestNewManifestBackedProvider_DefaultsPluralAndPreferredVersion(t *testing.
}
func TestSearchFieldProviders_MapsDeclaredKinds(t *testing.T) {
- got := SearchFieldProviders([]app.Manifest{manifestWithSearchFields()})
+ got, err := SearchFieldProviders([]app.Manifest{manifestWithSearchFields()})
+ require.NoError(t, err)
// The one kind that declares search fields is present and provider-backed.
require.Len(t, got, 1)
@@ -116,6 +117,29 @@ func TestSearchFieldProviders_NoManifestDeclarationsIsEmpty(t *testing.T) {
}},
}}
- got := SearchFieldProviders([]app.Manifest{manifestNoFields})
+ got, err := SearchFieldProviders([]app.Manifest{manifestNoFields})
+ require.NoError(t, err)
assert.Empty(t, got)
}
+
+func TestSearchFieldProviders_InvalidManifestReturnsError(t *testing.T) {
+ // A runtime manifest source can carry an invalid declaration; the path must
+ // surface it as an error rather than panicking.
+ invalid := app.Manifest{ManifestData: &app.ManifestData{
+ Group: "widgets.example.test",
+ Versions: []app.ManifestVersion{{
+ Name: "v1",
+ Kinds: []app.ManifestVersionKind{{
+ Kind: "Widget",
+ Plural: "widgets",
+ SearchFields: []app.ManifestVersionKindSearchField{
+ {Name: "size", Path: "spec.size", Type: "int64", Capabilities: []string{"text"}},
+ },
+ }},
+ }},
+ }}
+
+ got, err := SearchFieldProviders([]app.Manifest{invalid})
+ require.Error(t, err)
+ assert.Nil(t, got)
+}
diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go
index a268b12a3e72..fd8d7f82ed58 100644
--- a/pkg/storage/unified/search/options.go
+++ b/pkg/storage/unified/search/options.go
@@ -99,7 +99,10 @@ func NewSearchOptions(
searchFieldsHashes = resource.SearchFieldsHashesForBuilders(builders)
// Search fields come from the app manifests: every in-tree kind that
// has custom search fields declares them in its CUE manifest.
- searchFieldsProviders = resource.SearchFieldProviders(resource.AppManifests())
+ searchFieldsProviders, err = resource.SearchFieldProviders(resource.AppManifests())
+ if err != nil {
+ return resource.SearchOptions{}, err
+ }
}
bleve, err := NewBleveBackend(BleveOptions{