Denial of Service (resource exhaustion)
Description
The commit patches a potential Denial of Service (resource exhaustion) in Grafana provisioning. Previously, selective export (push and migrate) would fetch each explicitly listed resource individually, which meant that an unbounded or very large resources list could cause unbounded per-resource lookups and heavy CPU/memory usage. The fix caps the number of explicitly requested resources to 100 and validates this cap at admission time, preventing oversized provisioning jobs from entering processing and thereby mitigating DoS risk. Tests were added to verify behavior at and beyond the limit.
Proof of Concept
PoC Overview: An attacker could submit a provisioning job that requests a large number of resources in a selective export (push or migrate). Before this fix, there was no hard cap on the number of ResourceRef entries, so a very large list would trigger many per-resource lookups, potentially exhausting CPU, memory, or database resources.
Attack vector:
- Target Grafana with provisioning API access enabled.
- Submit a provisioning job (push or migrate) with an excessively large resources list (e.g., 1000 Dashboard refs).
- The server would attempt to resolve each resource individually, causing heavy resource usage and potential DoS.
Proof-of-Concept payload (illustrative; adjust endpoint to your Grafana deployment):
Python snippet to generate a large payload (e.g., 1000 resources):
import json
resources = [{"name": f"dash-{i}", "kind": "Dashboard"} for i in range(1000)]
payload = {
"action": "Push",
"repository": "poctest",
"push": {"resources": resources}
}
print(json.dumps(payload))
Submitting the above payload to the provisioning API (e.g., POST /api/provisioning/jobs) would, on systems without the cap, trigger hundreds-to-thousands of per-resource Get calls. The fix introduced in 12.4.0 caps at 100 resources and rejects larger lists at admission, preventing this DoS path.
Note: The actual API path and payload shape may vary by Grafana deployment. The key concept is that the Vulnerability lies in unbounded selective-export resource lists triggering excessive per-resource fetches; the fix enforces a hard cap of 100 resources and rejects oversized jobs."
Commit Details
Author: Roberto Jiménez Sánchez
Date: 2026-06-22 11:48 UTC
Message:
Provisioning: Limit selective export to 100 resources (#126949)
* Provisioning: Limit selective export to 100 resources
Selective export (push and migrate jobs with an explicit Resources list)
fetches each requested resource with an individual Get call, so an
unbounded list translates into an unbounded number of per-resource
lookups. Cap the list at 100 resources, validated at admission time so
oversized jobs are rejected before a worker picks them up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Add integration test
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Triage Assessment
Vulnerability Type: Denial of Service (resource exhaustion)
Confidence: HIGH
Reasoning:
Adds an explicit cap on the number of resources that can be selectively exported (100) and validates this at admission time. This prevents unbounded per-resource lookups that could be exploited to cause resource exhaustion or DoS during provisioning jobs. Tests were added to cover both the limit and over-limit cases.
Verification Assessment
Vulnerability Type: Denial of Service (resource exhaustion)
Confidence: HIGH
Affected Versions: < 12.4.0
Code Diff
diff --git a/apps/provisioning/pkg/jobs/validator.go b/apps/provisioning/pkg/jobs/validator.go
index 11ea77b7f4688..7e81c7ab5518e 100644
--- a/apps/provisioning/pkg/jobs/validator.go
+++ b/apps/provisioning/pkg/jobs/validator.go
@@ -136,11 +136,23 @@ func validateMigrateJobOptions(opts *provisioning.MigrateJobOptions, supportedRe
return list
}
+// MaxSelectiveExportResources caps how many resources a single export-style job
+// (push or migrate) may explicitly request. Selective export fetches each resource
+// individually, so an unbounded list would translate into an unbounded number of
+// per-resource lookups; this bound keeps a single job's work predictable.
+const MaxSelectiveExportResources = 100
+
// validateExportResourceRefs enforces the rules shared by export-style resource
// lists (push and migrate): name + kind are required, and each kind/group must
// match an active entry in the configured supported-resource set.
func validateExportResourceRefs(base *field.Path, refs []provisioning.ResourceRef, supportedResources []provisioning.SupportedResource) field.ErrorList {
list := field.ErrorList{}
+
+ if len(refs) > MaxSelectiveExportResources {
+ list = append(list, field.TooMany(base, len(refs), MaxSelectiveExportResources))
+ return list
+ }
+
supported := activeExportResources(supportedResources)
for i, r := range refs {
path := base.Index(i)
diff --git a/apps/provisioning/pkg/jobs/validator_test.go b/apps/provisioning/pkg/jobs/validator_test.go
index 8c2a8f96e2d0f..c61d5e4fa3d2d 100644
--- a/apps/provisioning/pkg/jobs/validator_test.go
+++ b/apps/provisioning/pkg/jobs/validator_test.go
@@ -2,6 +2,7 @@ package jobs
import (
"context"
+ "fmt"
"testing"
"time"
@@ -903,6 +904,56 @@ func TestValidateJob(t *testing.T) {
},
wantErr: false,
},
+ {
+ name: "push action at the selective export limit",
+ job: &provisioning.Job{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-job"},
+ Spec: provisioning.JobSpec{
+ Action: provisioning.JobActionPush,
+ Repository: "test-repo",
+ Push: &provisioning.ExportJobOptions{
+ Resources: makeDashboardRefs(MaxSelectiveExportResources),
+ },
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "push action over the selective export limit",
+ job: &provisioning.Job{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-job"},
+ Spec: provisioning.JobSpec{
+ Action: provisioning.JobActionPush,
+ Repository: "test-repo",
+ Push: &provisioning.ExportJobOptions{
+ Resources: makeDashboardRefs(MaxSelectiveExportResources + 1),
+ },
+ },
+ },
+ wantErr: true,
+ validateError: func(t *testing.T, err error) {
+ require.Contains(t, err.Error(), "spec.push.resources")
+ require.Contains(t, err.Error(), "must have at most 100 items")
+ },
+ },
+ {
+ name: "migrate action over the selective export limit",
+ job: &provisioning.Job{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-job"},
+ Spec: provisioning.JobSpec{
+ Action: provisioning.JobActionMigrate,
+ Repository: "test-repo",
+ Migrate: &provisioning.MigrateJobOptions{
+ Resources: makeDashboardRefs(MaxSelectiveExportResources + 1),
+ },
+ },
+ },
+ wantErr: true,
+ validateError: func(t *testing.T, err error) {
+ require.Contains(t, err.Error(), "spec.migrate.resources")
+ require.Contains(t, err.Error(), "must have at most 100 items")
+ },
+ },
}
for _, tt := range tests {
@@ -1126,6 +1177,15 @@ func TestHistoricJobAdmissionValidator_Validate(t *testing.T) {
}
}
+// makeDashboardRefs builds n valid dashboard resource refs for limit tests.
+func makeDashboardRefs(n int) []provisioning.ResourceRef {
+ refs := make([]provisioning.ResourceRef, n)
+ for i := range refs {
+ refs[i] = provisioning.ResourceRef{Name: fmt.Sprintf("dash-%d", i), Kind: "Dashboard"}
+ }
+ return refs
+}
+
func newHistoricJobAdmissionTestAttributes(obj runtime.Object, op admission.Operation) admission.Attributes {
return admission.NewAttributesRecord(
obj,
diff --git a/pkg/tests/apis/provisioning/jobs/job_validation_test.go b/pkg/tests/apis/provisioning/jobs/job_validation_test.go
index 72a05c4a04061..4f5cb2b9504d6 100644
--- a/pkg/tests/apis/provisioning/jobs/job_validation_test.go
+++ b/pkg/tests/apis/provisioning/jobs/job_validation_test.go
@@ -29,6 +29,15 @@ func TestIntegrationProvisioning_JobValidation(t *testing.T) {
}
helper.CreateLocalRepo(t, testRepo)
+ // Build a resource list that exceeds the selective-export cap (100).
+ overLimitResources := make([]map[string]interface{}, 101)
+ for i := range overLimitResources {
+ overLimitResources[i] = map[string]interface{}{
+ "name": fmt.Sprintf("dash-%d", i),
+ "kind": "Dashboard",
+ }
+ }
+
tests := []struct {
name string
jobSpec map[string]interface{}
@@ -260,6 +269,28 @@ func TestIntegrationProvisioning_JobValidation(t *testing.T) {
},
expectedErr: "spec.push.resources[0].kind: Invalid value: \"LibraryPanel\": kind is not supported for export",
},
+ {
+ name: "push job exceeding the selective export resource limit",
+ jobSpec: map[string]interface{}{
+ "action": string(provisioning.JobActionPush),
+ "repository": repo,
+ "push": map[string]interface{}{
+ "resources": overLimitResources,
+ },
+ },
+ expectedErr: "spec.push.resources: Too many: 101: must have at most 100 items",
+ },
+ {
+ name: "migrate job exceeding the selective export resource limit",
+ jobSpec: map[string]interface{}{
+ "action": string(provisioning.JobActionMigrate),
+ "repository": repo,
+ "migrate": map[string]interface{}{
+ "resources": overLimitResources,
+ },
+ },
+ expectedErr: "spec.migrate.resources: Too many: 101: must have at most 100 items",
+ },
}
for i, tt := range tests {