Denial of Service (Resource exhaustion via excessive JSON nesting during dashboard parsing)

HIGH
grafana/grafana
Commit: 40586837cbd3
Affected: Grafana 12.3.x and earlier (pre-12.4.0); 12.4.0 includes the fix
2026-05-29 15:25 UTC

Description

The commit implements explicit recursion depth limits when computing dashboard summaries to prevent denial-of-service via crafted dashboards with deeply nested specs/panels. It introduces maxSpecDepth and maxPanelDepth, guards recursive reads for spec and panels, and logs when limits are hit. Tests were added to verify recursion bounds. This changes the parsing/processing code path that could previously recurse without bounds, thereby enabling resource exhaustion under crafted input.

Proof of Concept

PoC idea and steps: 1) Craft a dashboard payload with spec nesting depth greater than the configured limit (e.g., 60 nested layers). Example structure: {"title":"deep","spec":{"spec":{"spec":{...}}}} where the nesting depth >> 1 (the fix sets maxSpecDepth = 1). 2) Send this payload to Grafana via the endpoint that triggers dashboard parsing for search/summary (endpoint varies by deployment; use the endpoint that consumes dashboard JSON for summary or search, e.g., a POST to a dashboards/search-like API). 3) Observe high CPU/memory usage or logs indicating recursion depth were bounded (the fix logs when limits are hit). In pre-fix versions, the server could attempt unbounded recursion leading to DoS. Concrete PoC snippet (replace the URL with your Grafana instance): import json, requests def nest(depth): payload = {"title": "deep"} for _ in range(depth): payload = {"spec": payload} return payload data = nest(60) # depth well beyond maxSpecDepth resp = requests.post("https://grafana.example.com/api/search", json=data) print(resp.status_code) Note: The exact endpoint depends on Grafana version and deployment; the key is to submit a dashboard-like JSON payload with excessive nesting to trigger the parsing path.

Commit Details

Author: Renato Costa

Date: 2026-05-29 14:48 UTC

Message:

dashboard: limit recursion when computing dashboard summary (#125687) Reduce the amount of work and log pollution caused by a bad input when computing dashboard summary for search.

Triage Assessment

Vulnerability Type: Denial of Service (Resource exhaustion via excessive JSON nesting)

Confidence: HIGH

Reasoning:

The patch adds explicit depth limits (maxSpecDepth, maxPanelDepth) and guards against deep/nested dashboard specs and panels during JSON parsing. It also logs when limits are hit. This mitigates potential DoS via crafted input that could cause unbounded recursion or excessive processing, which is a security vulnerability. Tests were added to verify recursion bounds.

Verification Assessment

Vulnerability Type: Denial of Service (Resource exhaustion via excessive JSON nesting during dashboard parsing)

Confidence: HIGH

Affected Versions: Grafana 12.3.x and earlier (pre-12.4.0); 12.4.0 includes the fix

Code Diff

diff --git a/pkg/services/store/kind/dashboard/dashboard.go b/pkg/services/store/kind/dashboard/dashboard.go index 9d0b3686f3c07..7d8a990ebd693 100644 --- a/pkg/services/store/kind/dashboard/dashboard.go +++ b/pkg/services/store/kind/dashboard/dashboard.go @@ -12,6 +12,13 @@ import ( "github.com/grafana/grafana/pkg/infra/log" ) +const ( + // A resource has exactly one spec (the k8s wrapper). Anything deeper is untrusted nesting. + maxSpecDepth = 1 + // Classic dashboards nest rows one level; allow generous headroom while bounding recursion. + maxPanelDepth = 4 +) + type templateVariable struct { current struct { value any @@ -119,11 +126,11 @@ func ReadDashboard(stream io.Reader, lookup DatasourceLookup) (*DashboardSummary func ReadDashboardWithLogContext(stream io.Reader, lookup DatasourceLookup, logContext map[string]any) (*DashboardSummaryInfo, error) { iter := jsoniter.Parse(jsoniter.ConfigDefault, stream, 1024) - return readDashboardIter("$", iter, lookup, logContext) + return readDashboardIter("$", iter, lookup, logContext, 0) } // nolint:gocyclo -func readDashboardIter(jsonPath string, iter *jsoniter.Iterator, lookup DatasourceLookup, lc map[string]any) (*DashboardSummaryInfo, error) { +func readDashboardIter(jsonPath string, iter *jsoniter.Iterator, lookup DatasourceLookup, lc map[string]any, specDepth int) (*DashboardSummaryInfo, error) { dash := &DashboardSummaryInfo{} if !checkAndSkipUnexpectedElement(iter, jsonPath, lc, jsoniter.ObjectValue) { @@ -146,7 +153,12 @@ func readDashboardIter(jsonPath string, iter *jsoniter.Iterator, lookup Datasour // recursively read the spec as dashboard json case "spec": - return readDashboardIter(jsonPath+".spec", iter, lookup, lc) + if specDepth >= maxSpecDepth { + logRecursionLimit(jsonPath+".spec", "spec", lc) + iter.Skip() + continue + } + return readDashboardIter(jsonPath+".spec", iter, lookup, lc, specDepth+1) case "id": if !checkAndSkipUnexpectedElement(iter, jsonPath+".id", lc, jsoniter.NumberValue) { @@ -263,7 +275,7 @@ func readDashboardIter(jsonPath string, iter *jsoniter.Iterator, lookup Datasour } for ix := 0; iter.ReadArray(); ix++ { - p, ok := readpanelInfo(iter, lookup, fmt.Sprintf("%s[%d]", panelsPath, ix), lc) + p, ok := readpanelInfo(iter, lookup, fmt.Sprintf("%s[%d]", panelsPath, ix), lc, 0) if ok { dash.Panels = append(dash.Panels, p) } @@ -421,6 +433,17 @@ func checkAndSkipUnexpectedElement(iter *jsoniter.Iterator, jsonPath string, log return false } +// logRecursionLimit logs when a nested element is skipped because it exceeds the maximum +// allowed nesting depth, mirroring how checkAndSkipUnexpectedElement appends log context. +func logRecursionLimit(jsonPath, field string, logContext map[string]any) { + params := make([]any, 0, 4+2*len(logContext)) + params = append(params, "jsonPath", jsonPath, "field", field) + for k, v := range logContext { + params = append(params, k, v) + } + logger.Error("Dashboard JSON nesting exceeds maximum depth", params...) +} + func valueTypesToString(allowedValues ...jsoniter.ValueType) string { expected := strings.Builder{} for ix, a := range allowedValues { @@ -538,7 +561,7 @@ func findDatasourceRefsForVariables(dsVariableRefs []DataSourceRef, datasourceVa } // nolint:gocyclo -func readpanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup, jsonPath string, lc map[string]any) (PanelSummaryInfo, bool) { +func readpanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup, jsonPath string, lc map[string]any, depth int) (PanelSummaryInfo, bool) { panel := PanelSummaryInfo{} if !checkAndSkipUnexpectedElement(iter, jsonPath, lc, jsoniter.ObjectValue) { @@ -657,12 +680,18 @@ func readpanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup, jsonPath st // Rows have nested panels case "panels": + if depth >= maxPanelDepth { + logRecursionLimit(jsonPath+".panels", "panels", lc) + iter.Skip() + continue + } + if !checkAndSkipUnexpectedElement(iter, jsonPath+".panels", lc, jsoniter.ArrayValue) { continue } for ix := 0; iter.ReadArray(); ix++ { - p, ok := readpanelInfo(iter, lookup, fmt.Sprintf("%s.panels[%d]", jsonPath, ix), lc) + p, ok := readpanelInfo(iter, lookup, fmt.Sprintf("%s.panels[%d]", jsonPath, ix), lc, depth+1) if ok { panel.Collapsed = append(panel.Collapsed, p) } diff --git a/pkg/services/store/kind/dashboard/dashboard_test.go b/pkg/services/store/kind/dashboard/dashboard_test.go index 086dfae98a327..6d49a8a804647 100644 --- a/pkg/services/store/kind/dashboard/dashboard_test.go +++ b/pkg/services/store/kind/dashboard/dashboard_test.go @@ -2,6 +2,7 @@ package dashboard import ( "encoding/json" + "fmt" "os" "path/filepath" "sort" @@ -120,6 +121,58 @@ func TestReadDashboard(t *testing.T) { } } +// TestReadDashboardRecursionLimits ensures that maliciously deep nesting of `spec` or +// `panels` is bounded so the parser cannot be driven into unbounded recursion. +func TestReadDashboardRecursionLimits(t *testing.T) { + t.Run("deeply nested spec terminates and does not recurse past the limit", func(t *testing.T) { + // {"spec":{"spec":{ ... {"title":"deep"} ... }}} + json := `{"title":"deep"}` + for i := 0; i < 100; i++ { + json = `{"spec":` + json + `}` + } + + dash, err := ReadDashboard(strings.NewReader(json), dsLookupForTests()) + require.NoError(t, err) + require.NotNil(t, dash) + // Only one spec is ever followed, so the inner "deep" title is never reached. + require.Empty(t, dash.Title) + }) + + t.Run("deeply nested panels are bounded by maxPanelDepth", func(t *testing.T) { + // A chain of nested rows, each carrying the next in its "panels" array. + panel := `{"type":"row","id":100,"panels":[]}` + for i := 99; i >= 0; i-- { + panel = fmt.Sprintf(`{"type":"row","id":%d,"panels":[%s]}`, i, panel) + } + json := fmt.Sprintf(`{"title":"nested","panels":[%s]}`, panel) + + dash, err := ReadDashboard(strings.NewReader(json), dsLookupForTests()) + require.NoError(t, err) + require.NotNil(t, dash) + require.Len(t, dash.Panels, 1) + + // Walk the Collapsed chain; the depth must be bounded regardless of input depth. + depth := 0 + for panels := dash.Panels; len(panels) > 0; panels = panels[0].Collapsed { + depth++ + } + // The top-level panel is read at depth 0 and may recurse up to maxPanelDepth, + // yielding maxPanelDepth+1 panel objects in the chain. + require.Equal(t, maxPanelDepth+1, depth) + }) + + t.Run("a single spec and one-level row nesting parse normally", func(t *testing.T) { + json := `{"apiVersion":"v1","kind":"Dashboard","metadata":{},"spec":{` + + `"title":"ok","panels":[{"type":"row","id":1,"panels":[{"type":"timeseries","id":2}]}]}}` + + dash, err := ReadDashboard(strings.NewReader(json), dsLookupForTests()) + require.NoError(t, err) + require.Equal(t, "ok", dash.Title) + require.Len(t, dash.Panels, 1) + require.Len(t, dash.Panels[0].Collapsed, 1) + }) +} + // assure consistent ordering of datasources to prevent random failures of `assert.JSONEq` func sortDatasources(dash *DashboardSummaryInfo) { sort.Slice(dash.Datasource, func(i, j int) bool {
← Back to Alerts View on GitHub →