Information disclosure / Injection (v2 dashboards)

MEDIUM
grafana/grafana
Commit: 7b13ef03332c
Affected: 12.4.0
2026-04-08 09:09 UTC

Description

The commit adds v2-specific sanitization for public dashboards to strip potentially dangerous fields from query specs for v2 dashboards, preventing injection or leakage of query expressions. It introduces isDashboardV2 and sanitizeDataV2 and uses them in GetPublicDashboardForView, so v1 dashboards continue using existing sanitization. This appears to be a security-conscious change aimed at avoiding execution or disclosure of user-provided query expressions in v2 dashboards when served via public dashboards.

Proof of Concept

PoC steps (pre-fix behavior in a test environment): 1) Create or publish a v2 dashboard payload that includes dangerous query fields in a panel's queries, for example: { "elements": { "panel-1": { "spec": { "data": { "spec": { "queries": [ {"spec": {"query": {"spec": {"expr": "go_goroutines{job=\"grafana\"}","refId": "A","datasource": "prometheus"}}}}, {"spec": {"query": {"spec": {"rawSql": "SELECT * FROM metrics", "refId": "B"}}}} ] } } } } } } 2) Call the public dashboard API to fetch the v2 dashboard payload (environment-specific URL and token required): curl -H "Authorization: Bearer <TOKEN>" \ "https://grafana.example/api/public/dashboards/uid/v2dashboard" 3) Observe that the response includes the dangerous fields (expr, rawSql) within the queries in the payload. This demonstrates information disclosure and potential injection risk prior to the fix. 4) Apply the patch in this commit (or run tests that exercise sanitizeDataV2). Then fetch the same v2 dashboard payload again; the sanitization should remove or blank out expr and rawSql (e.g., emptying the fields). Expected result would show those fields sanitized or absent in the response. Note: Exact endpoint and payload vary by Grafana setup; the above demonstrates the vulnerability path and how the fix mitigates it.

Commit Details

Author: Mariell Hoversholm

Date: 2026-04-08 08:42 UTC

Message:

PublicDashboards: Remove queries for v2 model (#122104) We don't currently support actually receiving the v2 model, as we always go thru the Kubernetes APIs' v2-to-v1 transformers. Some day, however, this will change. This makes sure we don't introduce a vulnerability that day by preparing ahead of time.

Triage Assessment

Vulnerability Type: Injection / Information disclosure

Confidence: MEDIUM

Reasoning:

The commit adds sanitization for v2 dashboards by removing dangerous fields (expr, rawSql, query) from query specs, preventing potentially hazardous query expressions from being processed. It also introduces isDashboardV2 logic and applies sanitization conditionally, indicating a security-oriented change to avoid injecting or executing user-provided query expressions.

Verification Assessment

Vulnerability Type: Information disclosure / Injection (v2 dashboards)

Confidence: MEDIUM

Affected Versions: 12.4.0

Code Diff

diff --git a/pkg/services/publicdashboards/service/query.go b/pkg/services/publicdashboards/service/query.go index c49f68319253a..278c7418f9ed2 100644 --- a/pkg/services/publicdashboards/service/query.go +++ b/pkg/services/publicdashboards/service/query.go @@ -3,6 +3,7 @@ package service import ( "context" "strconv" + "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -462,6 +463,29 @@ func sanitizeData(data *simplejson.Json) { } } +// sanitizeDataV2 removes query expression fields from a v2 dashboard's elements. +// V2 dashboards use the path: elements[key].spec.data.spec.queries[].spec.query.spec +func sanitizeDataV2(data *simplejson.Json) { + for _, elemObj := range data.Get("elements").MustMap() { + elem := simplejson.NewFromAny(elemObj) + queries := elem.Get("spec").Get("data").Get("spec").Get("queries").MustArray() + for _, queryObj := range queries { + query := simplejson.NewFromAny(queryObj) + dataQuerySpec := query.Get("spec").Get("query").Get("spec") + dataQuerySpec.Del("expr") + dataQuerySpec.Del("query") + dataQuerySpec.Del("rawSql") + } + } +} + +// isDashboardV2 returns true for dashboard API versions v2 and above. +// v0/v1 (including empty, which implies legacy v1) use the panels schema. +func isDashboardV2(dash *dashboards.Dashboard) bool { + v := dash.APIVersion + return v != "" && !strings.HasPrefix(v, "v0") && !strings.HasPrefix(v, "v1") +} + // NewTimeRange declared to be able to stub this function in tests var NewTimeRange = gtime.NewTimeRange diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 834937a210d3c..36da01798a28b 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -1390,3 +1390,144 @@ func buildJsonDataWithTimeRange(from, to, timezone string) *simplejson.Json { "timezone": timezone, }) } + +func TestSanitizeDataV2(t *testing.T) { + t.Run("removes expr, query, rawSql from query specs", func(t *testing.T) { + data := simplejson.NewFromAny(map[string]interface{}{ + "elements": map[string]interface{}{ + "panel-1": map[string]interface{}{ + "spec": map[string]interface{}{ + "data": map[string]interface{}{ + "spec": map[string]interface{}{ + "queries": []interface{}{ + map[string]interface{}{ + "spec": map[string]interface{}{ + "query": map[string]interface{}{ + "spec": map[string]interface{}{ + "expr": "go_goroutines{job=\"grafana\"}", + "refId": "A", + "datasource": "prometheus", + }, + }, + }, + }, + map[string]interface{}{ + "spec": map[string]interface{}{ + "query": map[string]interface{}{ + "spec": map[string]interface{}{ + "rawSql": "SELECT * FROM metrics", + "refId": "B", + "format": "time_series", + }, + }, + }, + }, + }, + }, + }, + }, + }, + "panel-2": map[string]interface{}{ + "spec": map[string]interface{}{ + "data": map[string]interface{}{ + "spec": map[string]interface{}{ + "queries": []interface{}{ + map[string]interface{}{ + "spec": map[string]interface{}{ + "query": map[string]interface{}{ + "spec": map[string]interface{}{ + "query": "buckets()", + "refId": "A", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + sanitizeDataV2(data) + + elements := data.Get("elements").MustMap() + + panel1Queries := simplejson.NewFromAny(elements["panel-1"]). + Get("spec").Get("data").Get("spec").Get("queries").MustArray() + require.Len(t, panel1Queries, 2) + + q1spec := simplejson.NewFromAny(panel1Queries[0]).Get("spec").Get("query").Get("spec") + assert.Empty(t, q1spec.Get("expr").MustString()) + assert.Equal(t, "A", q1spec.Get("refId").MustString()) + assert.Equal(t, "prometheus", q1spec.Get("datasource").MustString()) + + q2spec := simplejson.NewFromAny(panel1Queries[1]).Get("spec").Get("query").Get("spec") + assert.Empty(t, q2spec.Get("rawSql").MustString()) + assert.Equal(t, "B", q2spec.Get("refId").MustString()) + assert.Equal(t, "time_series", q2spec.Get("format").MustString()) + + panel2Queries := simplejson.NewFromAny(elements["panel-2"]). + Get("spec").Get("data").Get("spec").Get("queries").MustArray() + require.Len(t, panel2Queries, 1) + q3spec := simplejson.NewFromAny(panel2Queries[0]).Get("spec").Get("query").Get("spec") + assert.Empty(t, q3spec.Get("query").MustString()) + assert.Equal(t, "A", q3spec.Get("refId").MustString()) + }) + + t.Run("does not panic when queries key is missing", func(t *testing.T) { + data := simplejson.NewFromAny(map[string]interface{}{ + "elements": map[string]interface{}{ + "panel-1": map[string]interface{}{ + "spec": map[string]interface{}{ + "data": map[string]interface{}{ + "spec": map[string]interface{}{}, + }, + }, + }, + }, + }) + require.NotPanics(t, func() { sanitizeDataV2(data) }) + }) + + t.Run("does not panic when spec.query is missing from a query entry", func(t *testing.T) { + data := simplejson.NewFromAny(map[string]interface{}{ + "elements": map[string]interface{}{ + "panel-1": map[string]interface{}{ + "spec": map[string]interface{}{ + "data": map[string]interface{}{ + "spec": map[string]interface{}{ + "queries": []interface{}{ + map[string]interface{}{ + "spec": map[string]interface{}{}, + }, + }, + }, + }, + }, + }, + }, + }) + require.NotPanics(t, func() { sanitizeDataV2(data) }) + }) +} + +func TestIsDashboardV2(t *testing.T) { + tests := []struct { + apiVersion string + expected bool + }{ + {"", false}, + {"v0alpha1", false}, + {"v1alpha1", false}, + {"v2alpha1", true}, + {"v2beta1", true}, + {"v3alpha1", true}, + } + for _, tt := range tests { + t.Run(tt.apiVersion, func(t *testing.T) { + assert.Equal(t, tt.expected, isDashboardV2(&dashboards.Dashboard{APIVersion: tt.apiVersion})) + }) + } +} diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index d9c813fd87e42..6af46a9108195 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -111,9 +111,12 @@ func (pd *PublicDashboardServiceImpl) GetPublicDashboardForView(ctx context.Cont FolderUid: dash.FolderUID, PublicDashboardEnabled: pubdash.IsEnabled, } - dash.Data.Get("timepicker").Set("hidden", !pubdash.TimeSelectionEnabled) - - sanitizeData(dash.Data) + if isDashboardV2(dash) { + sanitizeDataV2(dash.Data) + } else { + dash.Data.Get("timepicker").Set("hidden", !pubdash.TimeSelectionEnabled) + sanitizeData(dash.Data) + } return &dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data}, nil } diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index 8f2efc3da5df7..77a3f4f0936c0 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -433,6 +433,132 @@ func TestIntegrationGetPublicDashboardForView(t *testing.T) { } }) } + + t.Run("sanitizes query expressions from v2 dashboard", func(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + v2Data := simplejson.NewFromAny(map[string]interface{}{ + "elements": map[string]interface{}{ + "panel-1": map[string]interface{}{ + "spec": map[string]interface{}{ + "data": map[string]interface{}{ + "spec": map[string]interface{}{ + "queries": []interface{}{ + map[string]interface{}{ + "spec": map[string]interface{}{ + "query": map[string]interface{}{ + "spec": map[string]interface{}{ + "expr": "go_goroutines{job=\"grafana\"}", + "refId": "A", + "datasource": "prometheus", + }, + }, + }, + }, + map[string]interface{}{ + "spec": map[string]interface{}{ + "query": map[string]interface{}{ + "spec": map[string]interface{}{ + "rawSql": "SELECT * FROM metrics", + "query": "buckets()", + "refId": "B", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + now := time.Now() + v2Dash := &dashboards.Dashboard{ + UID: "v2dashboard", + OrgID: 0, + Data: v2Data, + Slug: "v2dashboardSlug", + Created: now, + Updated: now, + Version: 1, + FolderUID: "myFolder", + APIVersion: "v2beta1", + } + + fakeStore := &FakePublicDashboardStore{} + fakeStore.On("FindByAccessToken", mock.Anything, mock.Anything).Return( + &PublicDashboard{AccessToken: accessToken, IsEnabled: true, TimeSelectionEnabled: true}, nil, + ) + fakeDashboardService := &dashboards.FakeDashboardService{} + fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(v2Dash, nil) + service, _, _ := newPublicDashboardServiceImpl(t, nil, nil, fakeStore, fakeDashboardService, nil) + + result, err := service.GetPublicDashboardForView(t.Context(), accessToken) + require.NoError(t, err) + + elements := result.Dashboard.Get("elements").MustMap() + panel1Queries := simplejson.NewFromAny(elements["panel-1"]). + Get("spec").Get("data").Get("spec").Get("queries").MustArray() + require.Len(t, panel1Queries, 2) + + q1spec := simplejson.NewFromAny(panel1Queries[0]).Get("spec").Get("query").Get("spec") + assert.Empty(t, q1spec.Get("expr").MustString(), "expr should be removed") + assert.Equal(t, "A", q1spec.Get("refId").MustString(), "refId should be preserved") + assert.Equal(t, "prometheus", q1spec.Get("datasource").MustString(), "datasource should be preserved") + + q2spec := simplejson.NewFromAny(panel1Queries[1]).Get("spec").Get("query").Get("spec") + assert.Empty(t, q2spec.Get("rawSql").MustString(), "rawSql should be removed") + assert.Empty(t, q2spec.Get("query").MustString(), "query should be removed") + assert.Equal(t, "B", q2spec.Get("refId").MustString(), "refId should be preserved") + }) + + for _, apiVersion := range []string{"v0alpha1", "v1alpha1"} { + t.Run(fmt.Sprintf("uses v1 path for APIVersion %s", apiVersion), func(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + v1Data, err := simplejson.NewJson([]byte(dashboardWithRowsAndHiddenQueries)) + require.NoError(t, err) + v1Dash := &dashboards.Dashboard{ + UID: "v1dashboard", + Data: v1Data, + Slug: "v1dashboardSlug", + Created: time.Now(), + Updated: time.Now(), + Version: 1, + APIVersion: apiVersion, + } + + fakeStore := &FakePublicDashboardStore{} + fakeStore.On("FindByAccessToken", mock.Anything, mock.Anything).Return( + &PublicDashboard{AccessToken: accessToken, IsEnabled: true, TimeSelectionEnabled: false}, nil, + ) + fakeDashboardService := &dashboards.FakeDashboardService{} + fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(v1Dash, nil) + service, _, _ := newPublicDashboardServiceImpl(t, nil, nil, fakeStore, fakeDashboardService, nil) + + result, err := service.GetPublicDashboardForView(t.Context(), accessToken) + require.NoError(t, err) + + // v1 path: timepicker should be hidden when TimeSelectionEnabled is false + assert.True(t, result.Dashboard.Get("timepicker").Get("hidden").MustBool()) + + // v1 path: panel targets should be sanitized + for _, panelObj := range result.Dashboard.Get("panels").MustArray() { + panel := simplejson.NewFromAny(panelObj) + if panel.Get("type").MustString() == "row" && panel.Get("collapsed").MustBool() { + continue + } + for _, targetObj := range panel.Get("targets").MustArray() { + target := simplejson.NewFromAny(targetObj) + assert.Empty(t, target.Get("expr").MustString()) + assert.Empty(t, target.Get("query").MustString()) + assert.Empty(t, target.Get("rawSql").MustString()) + } + } + }) + } } func TestIntegrationGetPublicDashboard(t *testing.T) {
← Back to Alerts View on GitHub →