Authorization bypass / Information disclosure

HIGH
grafana/grafana
Commit: a88567fff8e6
Affected: < 12.4.0
2026-05-28 22:34 UTC

Description

Security fix verified. The commit adds per-request authorization filtering when retrieving datasources by type, ensuring that only datasources the caller has read access to are returned. This mitigates an information disclosure/authorization bypass path where unauthorized users could learn about datasources they should not see. The patch implements filtering in GetDataSourcesByType by enumerating the results and validating each datasource against the caller's read permissions. It also highlights a potential risky code path in ListConnections where a plugin-type query could bypass service-level authorization if not carefully wired, which the tests exercise to confirm proper behavior. Overall, this is a genuine vulnerability fix for information disclosure via datasource-type queries.

Proof of Concept

Proof-of-concept (actionable steps): Prerequisites: - Grafana server running with datasource type 'test' and three datasources: uid 'aaa', 'bbb', 'ccc'. - A user (or token) that has read permission only for datasource 'aaa' (not 'bbb' or 'ccc'). - The vulnerability path is exercised via the API that lists datasources by type (e.g., GET /api/datasources/type/:type). 1) With the vulnerability present (pre-fix behavior): - Authenticate as the limited user and request: curl -H 'Authorization: Bearer <token>' http://grafana.example/api/datasources/type/test - Expected (incorrect) result prior to the fix: the response includes all datasources of that type, e.g., ['aaa','bbb','ccc'], regardless of per-resource read permissions. 2) With the fix applied (as in this commit): - Authenticate as the same limited user and request the same endpoint. - Expected (correct) result after the fix: the response only includes datasources the user has read access to, e.g., ['aaa']. 3) PoC code (Python): import requests GRAFANA_URL = "http://grafana.example" TOKEN = "<token_with_only_aaa_read_permission>" TYPE = "test" headers = {"Authorization": f"Bearer {TOKEN}"} resp = requests.get(f"{GRAFANA_URL}/api/datasources/type/{TYPE}", headers=headers) print(resp.status_code) print([ds.get('uid') for ds in resp.json()]) Notes: - If the endpoint returns all UIDs ('aaa','bbb','ccc') for the limited user, that demonstrates the vulnerability. If it returns only ['aaa'], the fix is in effect. - This PoC assumes the Grafana API endpoint for listing datasources by type is /api/datasources/type/:type and that the response is a JSON array of datasource objects containing a uid field.

Commit Details

Author: Ryan McKinley

Date: 2026-05-28 21:37 UTC

Message:

Datasources: apply caller read scope when filtering by type (#125624)

Triage Assessment

Vulnerability Type: Authorization bypass / Information disclosure

Confidence: HIGH

Reasoning:

The patch adds per-request authorization filtering when retrieving data sources by type, ensuring that only datasources the caller has read access to are returned. This closes potential information disclosure or privilege escalation paths where unauthorized users could see data sources they shouldn’t.

Verification Assessment

Vulnerability Type: Authorization bypass / Information disclosure

Confidence: HIGH

Affected Versions: < 12.4.0

Code Diff

diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index 8bbc9890680a2..4a10de4e5f0ad 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -216,7 +216,29 @@ func (s *Service) GetDataSourcesByType(ctx context.Context, query *datasources.G } query.AliasIDs = p.AliasIDs } - return s.SQLStore.GetDataSourcesByType(ctx, query) + + all, err := s.SQLStore.GetDataSourcesByType(ctx, query) + if err != nil { + return nil, err + } + + // System/background callers have no requester in context — return all values. + user, err := identity.GetRequester(ctx) + if err != nil || user == nil { + return all, nil + } + + filtered := make([]*datasources.DataSource, 0, len(all)) + for _, ds := range all { + // Skip datasources they can not see + evaluator := accesscontrol.EvalPermission(datasources.ActionRead, + datasources.ScopeProvider.GetResourceScopeUID(ds.UID)) + if ok, _ := s.ac.Evaluate(ctx, user, evaluator); !ok { + continue + } + filtered = append(filtered, ds) + } + return filtered, nil } // ListConnections implements v0alpha1.DataSourceConnectionProvider. @@ -265,10 +287,16 @@ func (s *Service) ListConnections(ctx context.Context, query queryV0.DataSourceC dss = []*datasources.DataSource{ds} // will check authz before returning } } else if query.Plugin != "" { - dss, err = s.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{ + q := &datasources.GetDataSourcesByTypeQuery{ OrgID: ns.OrgID, - Type: query.Plugin, // will support alias - }) + Type: query.Plugin, + } + p, found := s.pluginStore.Plugin(ctx, query.Plugin) + if !found { + return nil, fmt.Errorf("plugin %s not found", query.Plugin) + } + q.AliasIDs = p.AliasIDs + dss, err = s.SQLStore.GetDataSourcesByType(ctx, q) // Authz NOT applied } else { dss, err = s.GetDataSources(ctx, &datasources.GetDataSourcesQuery{ OrgID: ns.OrgID, diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index f240f8182aec8..6d28923613caa 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -1536,6 +1536,124 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { }) } +func TestIntegrationService_GetDataSourcesByType(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + sqlStore := db.InitTestDB(t) + secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) + secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) + quotaService := quotatest.New(false, nil) + plgs := &pluginstore.FakePluginStore{ + PluginList: []pluginstore.Plugin{ + {JSONData: plugins.JSONData{ + ID: "test", + AliasIDs: []string{"grafana-testdata-datasource"}, + }}, + }, + } + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, plgs, &pluginfakes.FakePluginClient{}, nil, dsRetriever) + require.NoError(t, err) + + // Provision data sources using the privileged provisioning identity. + adminCtx, _, err := identity.WithProvisioningIdentity(context.Background(), "default") + require.NoError(t, err) + for _, uid := range []string{"aaa", "bbb", "ccc"} { + _, err = dsService.AddDataSource(adminCtx, &datasources.AddDataSourceCommand{ + OrgID: 1, + Name: "ds-" + uid, + UID: uid, + Type: "test", + }) + require.NoError(t, err) + } + + userWith := func(scopes ...string) context.Context { + return identity.WithRequester(context.Background(), &identity.StaticRequester{ + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: {datasources.ActionRead: scopes}, + }, + }) + } + + t.Run("returns all when user has wildcard read", func(t *testing.T) { + ctx := userWith(datasources.ScopeAll) + res, err := dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{ + OrgID: 1, + Type: "test", + }) + require.NoError(t, err) + ids := make([]string, 0, len(res)) + for _, ds := range res { + ids = append(ids, ds.UID) + } + require.ElementsMatch(t, []string{"aaa", "bbb", "ccc"}, ids) + }) + + t.Run("filters to only data sources the user can read", func(t *testing.T) { + ctx := userWith( + datasources.ScopeProvider.GetResourceScopeUID("aaa"), + datasources.ScopeProvider.GetResourceScopeUID("ccc"), + ) + res, err := dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{ + OrgID: 1, + Type: "test", + }) + require.NoError(t, err) + ids := make([]string, 0, len(res)) + for _, ds := range res { + ids = append(ids, ds.UID) + } + require.ElementsMatch(t, []string{"aaa", "ccc"}, ids) + }) + + t.Run("returns empty when user has no read access", func(t *testing.T) { + ctx := userWith() + res, err := dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{ + OrgID: 1, + Type: "test", + }) + require.NoError(t, err) + require.Empty(t, res) + }) + + t.Run("returns all (unfiltered) when no requester is in context", func(t *testing.T) { + // System/background callers have no requester and must not be filtered. + res, err := dsService.GetDataSourcesByType(context.Background(), &datasources.GetDataSourcesByTypeQuery{ + OrgID: 1, + Type: "test", + }) + require.NoError(t, err) + ids := make([]string, 0, len(res)) + for _, ds := range res { + ids = append(ids, ds.UID) + } + require.ElementsMatch(t, []string{"aaa", "bbb", "ccc"}, ids) + }) + + t.Run("resolves AliasIDs from plugin store when not provided", func(t *testing.T) { + ctx := userWith(datasources.ScopeAll) + // Query by an alias type; AliasIDs are populated via the plugin store. + res, err := dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{ + OrgID: 1, + Type: "test", + }) + require.NoError(t, err) + require.Len(t, res, 3) + }) + + t.Run("returns error when plugin is unknown", func(t *testing.T) { + ctx := userWith(datasources.ScopeAll) + _, err := dsService.GetDataSourcesByType(ctx, &datasources.GetDataSourcesByTypeQuery{ + OrgID: 1, + Type: "not-installed", + }) + require.Error(t, err) + }) +} + func TestIntegrationService_getConnections(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -1701,6 +1819,45 @@ func TestIntegrationService_getConnections(t *testing.T) { ] }`, string(jj)) }) + + t.Run("Should return error when plugin is unknown", func(t *testing.T) { + _, err := dsService.ListConnections(ctx, v0alpha1.DataSourceConnectionQuery{ + Namespace: "default", + Plugin: "not-installed", + }) + require.Error(t, err) + }) + + t.Run("Should filter connections by user permissions when querying by plugin", func(t *testing.T) { + // Provisioning identity has wildcard read. Swap in a user that can only see "aaa". + restrictedCtx := identity.WithRequester(context.Background(), &identity.StaticRequester{ + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: {datasources.ActionRead: {datasources.ScopeProvider.GetResourceScopeUID("aaa")}}, + }, + }) + res, err := dsService.ListConnections(restrictedCtx, v0alpha1.DataSourceConnectionQuery{ + Namespace: "default", + Plugin: "graphite", + }) + require.NoError(t, err) + require.Len(t, res.Items, 1) + require.Equal(t, "aaa", res.Items[0].Name) + + // Same query, user without the matching scope, returns nothing. + emptyCtx := identity.WithRequester(context.Background(), &identity.StaticRequester{ + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: {datasources.ActionRead: {datasources.ScopeProvider.GetResourceScopeUID("ccc")}}, + }, + }) + res, err = dsService.ListConnections(emptyCtx, v0alpha1.DataSourceConnectionQuery{ + Namespace: "default", + Plugin: "graphite", + }) + require.NoError(t, err) + require.Empty(t, res.Items) + }) } func TestIntegrationService_getProxySettings(t *testing.T) {
← Back to Alerts View on GitHub →