Information Disclosure / Access Control (Authorization)

HIGH
grafana/grafana
Commit: 7b60014cdd7d
Affected: Grafana 12.4.0 and earlier (prior to this commit)
2026-04-16 14:22 UTC

Description

The commit adds fieldSelector handling for the preferences listing endpoint and enforces access checks to prevent information disclosure via list operations. Specifically: - Only a single fieldSelector on metadata.name is supported with the = operator. - Access control is applied based on the owner parsed from the name: namespace owner is always allowed; user owner is allowed only if the requester matches; team owner is allowed only if the requester is a member of that team. - Continue tokens and label selectors are rejected to prevent abuse. - If access is not permitted, the API returns an empty list instead of leaking data. This fixes a potential authorization/info-disclosure flaw where an authenticated user could enumerate or fetch preferences for other users/teams via the list endpoint using field selectors. The vulnerability type is Information Disclosure / Access Control (Authorization).

Proof of Concept

PoC (exploit path prior to the fix): Assume an authenticated attacker A (UID: a1) is logged into Grafana. 1) Attempt to enumerate another user's preferences via fieldSelector on the list endpoint: - Request: curl -H "Authorization: Bearer <tokenA>" \ 'https://grafana.example/api/org/preferences?fieldSelector=metadata.name%3Duser-target' - Expected pre-fix behavior (vulnerable): The API could return the target user's preferences in the response (or allow unintended access) because there were no strict per-owner checks on list results obtained via fieldSelector. - With the fix: The server parses the owner from the name and ensures the requester A is allowed to view user-target’s preferences (A must be the same user). If not allowed, the API returns an empty list, not the target’s data. 2) Attempt to enumerate a team’s preferences via fieldSelector on the list endpoint: - Request: curl -H "Authorization: Bearer <tokenA>" \ 'https://grafana.example/api/org/preferences?fieldSelector=metadata.name%3Dteam-target' - Expected pre-fix behavior (vulnerable): If the requester is not a member of the team, the response could leak the team’s preferences or reveal that the team exists. - With the fix: The code checks team membership and only returns data if the requester is a member; otherwise an empty list is returned. Notes: - The PoC demonstrates how an attacker could use fieldSelector with metadata.name to fetch another user’s or a team’s preferences before this patch, enabling information disclosure via the list operation. The fix restricts access and returns empty results for non-owners or non-members.

Commit Details

Author: Ryan McKinley

Date: 2026-04-16 13:19 UTC

Message:

Preferences: Support fieldSelector for name (#122814)

Triage Assessment

Vulnerability Type: Information Disclosure / Access Control

Confidence: HIGH

Reasoning:

The changes add fieldSelector handling for preferences listing with strict validation (only metadata.name, only '=' operator) and implement access checks: it limits listing to allowed owners (namespace always OK, user only if requester matches, team membership checked). It also blocks continue tokens and label selectors. These changes reduce information disclosure and unauthorized access when listing user/team preferences, addressing authz/info leakage vulnerabilities in list operations.

Verification Assessment

Vulnerability Type: Information Disclosure / Access Control (Authorization)

Confidence: HIGH

Affected Versions: Grafana 12.4.0 and earlier (prior to this commit)

Code Diff

diff --git a/pkg/registry/apis/preferences/legacy/preferences.go b/pkg/registry/apis/preferences/legacy/preferences.go index 5cdd4c80e0155..ef74668af8b38 100644 --- a/pkg/registry/apis/preferences/legacy/preferences.go +++ b/pkg/registry/apis/preferences/legacy/preferences.go @@ -11,6 +11,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/selection" requestK8s "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" @@ -82,9 +83,61 @@ func (s *preferenceStorage) List(ctx context.Context, options *internalversion.L if user.GetIdentityType() != authlib.TypeUser { return nil, fmt.Errorf("only users may list preferences") } + if options.Continue != "" { + return nil, fmt.Errorf("continue token not supported") + } + if options.LabelSelector != nil && !options.LabelSelector.Empty() { + return nil, fmt.Errorf("labelSelector not supported") + } + + if options.FieldSelector != nil && !options.FieldSelector.Empty() { + r := options.FieldSelector.Requirements() + if len(r) != 1 { + return nil, fmt.Errorf("only one fieldSelector is supported") + } + if r[0].Field != "metadata.name" { + return nil, fmt.Errorf("only the metadata.name fieldSelector is supported") + } + if r[0].Operator != selection.Equals { + return nil, fmt.Errorf("only the = operator is supported") + } + return s.doListWithName(ctx, user, r[0].Value) + } + return s.sql.ListPreferences(ctx, ns, user, true) } +func (s *preferenceStorage) doListWithName(ctx context.Context, user identity.Requester, name string) (*preferences.PreferencesList, error) { + info, _ := utils.ParseOwnerFromName(name) + switch info.Owner { + case utils.NamespaceResourceOwner: + // OK + case utils.UserResourceOwner: + if user.GetIdentifier() != info.Identifier { + return &preferences.PreferencesList{}, nil + } + case utils.TeamResourceOwner: + ok, err := s.sql.InTeam(ctx, user, info.Identifier, false) + if err != nil || !ok { + return &preferences.PreferencesList{}, nil + } + default: + return &preferences.PreferencesList{}, nil + } + + obj, err := s.Get(ctx, name, &metav1.GetOptions{}) + if err != nil { + return &preferences.PreferencesList{}, nil + } + p, ok := obj.(*preferences.Preferences) + if !ok { + return &preferences.PreferencesList{}, nil + } + return &preferences.PreferencesList{ + Items: []preferences.Preferences{*p}, + }, nil +} + func (s *preferenceStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { ns, err := request.NamespaceInfoFrom(ctx, true) if err != nil { diff --git a/pkg/registry/apis/preferences/legacy/sql.go b/pkg/registry/apis/preferences/legacy/sql.go index f87e3edbe80f7..e63870f0d5585 100644 --- a/pkg/registry/apis/preferences/legacy/sql.go +++ b/pkg/registry/apis/preferences/legacy/sql.go @@ -9,7 +9,6 @@ import ( "time" authlib "github.com/grafana/authlib/types" - "github.com/grafana/grafana-app-sdk/logging" preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" pref "github.com/grafana/grafana/pkg/services/preference" @@ -65,9 +64,6 @@ func (s *LegacySQL) listPreferences(ctx context.Context, return nil, 0, fmt.Errorf("execute template %q: %w", sqlPreferencesQuery.Name(), err) } - // Debug the SQL query - logging.FromContext(ctx).Info("ListPreferences", "query", q, "args", req.GetArgs()) - sess := sql.DB.GetSqlxSession() rows, err := sess.Query(ctx, q, req.GetArgs()...) defer func() { diff --git a/pkg/tests/apis/preferences/preferences_test.go b/pkg/tests/apis/preferences/preferences_test.go index c4e4093bfc441..7a5806c267489 100644 --- a/pkg/tests/apis/preferences/preferences_test.go +++ b/pkg/tests/apis/preferences/preferences_test.go @@ -103,6 +103,21 @@ func TestIntegrationPreferences(t *testing.T) { v, _, _ = unstructured.NestedString(out.Object, "spec", "weekStart") require.Equal(t, "sunday", v) + // Fetch it again using list + list, err := clientAdmin.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "metadata.name=team-" + helper.Org1.Staff.UID, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + v, _, _ = unstructured.NestedString(list.Items[0].Object, "spec", "weekStart") + require.Equal(t, "sunday", v) + + // nothing found when asking for a user you can not see + _, err = clientAdmin.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "metadata.name=user-xxx", + }) + require.Error(t, err) // eventually this should be an empty list + // http://localhost:3000/api/org/preferences legacyResponse = apis.DoRequest(helper, apis.RequestParams{ User: clientAdmin.Args.User,
← Back to Alerts View on GitHub →