Authorization bypass / Information Disclosure
Description
The commit implements an authorization fix for listing preferences. Prior to this change, non-user identities (e.g., AccessPolicy) could list all preferences by nulling the user, effectively bypassing access control and enabling information disclosure across users/teams within a namespace. The patch restricts listing to actual users and requires an explicit All flag to list all preferences. This prevents an authorization bypass that could reveal per-user/per-team preferences.
Proof of Concept
PoC (conceptual, applicable to vulnerable versions prior to the fix):
1) Obtain or assume an identity with non-user type (e.g., AccessPolicy) that can authenticate to Grafana.
- This could be a service-account or integration identity used by internal tooling.
2) Call the API endpoint that lists preferences for a given namespace/org without a valid user context.
- Endpoint and exact path depend on Grafana deployment, but it generally corresponds to the ListPreferences API in the legacy preferences registry.
- Example (illustrative HTTP request; adapt to your Grafana instance):
curl -sS -H "Authorization: Bearer <ACCESS_POLICY_TOKEN>" \
https://grafana.example/api/registry/preferences?ns=grafana
3) Expected vulnerable behavior (pre-fix): the response would return a complete list of preferences across users and teams in the org, e.g. a JSON array containing entries like:
[
{"org_id": 1, "user_uid": "alice", "team_uid": null, "prefs": {"theme": "dark"}},
{"org_id": 1, "user_uid": "bob", "team_uid": null, "prefs": {"theme": "light"}},
{"org_id": 1, "user_uid": null, "team_uid": "team-a", "prefs": {"homeDash": "dashboard/1"}},
...
]
This exposes per-user and per-team preferences to non-user identities.
4) Impact: attacker could enumerate configuration data tied to users/teams, facilitating information disclosure and potential abuse of user-specific settings.
Fix behavior (post-fix) to contrast with PoC: The API now rejects non-user identities with a clear error (e.g., "only users may list preferences"), and requires an explicit All flag to list all preferences if needed. If you test against the fixed version, the curl request above should fail with a 403 or the explicit error message instead of returning the data.
Commit Details
Author: Ryan McKinley
Date: 2026-04-15 08:43 UTC
Message:
Preferences: Improve list behavior (fix orphaned teams) (#122639)
Triage Assessment
Vulnerability Type: Information Disclosure / Authorization bypass
Confidence: HIGH
Reasoning:
The patch changes access control for listing preferences. Previously, non-user identities (AccessPolicy) could list all preferences by nulling the user, potentially leaking data. Now only users are allowed to list, returning an error for non-user identities. This fixes an authorization bypass/information disclosure vulnerability.
Verification Assessment
Vulnerability Type: Authorization bypass / Information Disclosure
Confidence: HIGH
Affected Versions: <= 12.4.0 (pre-fix)
Code Diff
diff --git a/pkg/registry/apis/preferences/legacy/preferences.go b/pkg/registry/apis/preferences/legacy/preferences.go
index 5287f251f0702..e407dddfe3dcd 100644
--- a/pkg/registry/apis/preferences/legacy/preferences.go
+++ b/pkg/registry/apis/preferences/legacy/preferences.go
@@ -78,8 +78,8 @@ func (s *preferenceStorage) List(ctx context.Context, options *internalversion.L
return nil, err
}
ns := requestK8s.NamespaceValue(ctx)
- if user.GetIdentityType() == authlib.TypeAccessPolicy {
- user = nil // nill user can see everything
+ if user.GetIdentityType() != authlib.TypeUser {
+ return nil, fmt.Errorf("only users may list preferences")
}
return s.sql.ListPreferences(ctx, ns, user, true)
}
@@ -108,8 +108,6 @@ func (s *preferenceStorage) Get(ctx context.Context, name string, options *metav
default:
return false, fmt.Errorf("unsupported name")
}
- }, func(p *preferenceModel) bool {
- return true
})
if err != nil {
return nil, err
diff --git a/pkg/registry/apis/preferences/legacy/queries.go b/pkg/registry/apis/preferences/legacy/queries.go
index 8ec075ec6def2..0e35e2b672d78 100644
--- a/pkg/registry/apis/preferences/legacy/queries.go
+++ b/pkg/registry/apis/preferences/legacy/queries.go
@@ -38,6 +38,7 @@ type preferencesQuery struct {
UserUID string
UserTeams []string // also requires user UID
TeamUID string
+ All bool // explicitly request all preferences
UserTable string
TeamTable string
@@ -59,6 +60,12 @@ func (r preferencesQuery) Validate() error {
if len(r.UserTeams) > 0 && r.UserUID == "" {
return fmt.Errorf("user required when filtering by a set of teams")
}
+ if r.UserUID == "" && r.TeamUID == "" {
+ if r.All {
+ return nil // OK
+ }
+ return fmt.Errorf("to list all preferences, explicitly set the .All flag")
+ }
return nil
}
diff --git a/pkg/registry/apis/preferences/legacy/queries_test.go b/pkg/registry/apis/preferences/legacy/queries_test.go
index 8b4856a04dcce..58587fad02dde 100644
--- a/pkg/registry/apis/preferences/legacy/queries_test.go
+++ b/pkg/registry/apis/preferences/legacy/queries_test.go
@@ -9,7 +9,7 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks"
)
-func TestStarsQueries(t *testing.T) {
+func TestPreferencesQueries(t *testing.T) {
// prefix tables with grafana
nodb := &legacysql.LegacyDatabaseHelper{
Table: func(n string) string {
@@ -35,9 +35,28 @@ func TestStarsQueries(t *testing.T) {
SQLTemplatesFS: sqlTemplatesFS,
Templates: map[*template.Template][]mocks.TemplateTestCase{
sqlPreferencesQuery: {
+ {
+ Name: "all-error",
+ Data: getPreferencesQuery(1, func(q *preferencesQuery) {}),
+ ValidationError: "to list all preferences, explicitly set the .All flag",
+ },
+ {
+ Name: "missing-org",
+ Data: getPreferencesQuery(0, func(q *preferencesQuery) {}),
+ ValidationError: "must include an orgID",
+ },
+ {
+ Name: "missing-user",
+ Data: getPreferencesQuery(1, func(q *preferencesQuery) {
+ q.UserTeams = []string{"a"}
+ }),
+ ValidationError: "user required when filtering by a set of teams",
+ },
{
Name: "all",
- Data: getPreferencesQuery(1, func(q *preferencesQuery) {}),
+ Data: getPreferencesQuery(1, func(q *preferencesQuery) {
+ q.All = true
+ }),
},
{
Name: "user-no-teams",
@@ -69,7 +88,9 @@ func TestStarsQueries(t *testing.T) {
sqlPreferencesRV: {
{
Name: "get",
- Data: getPreferencesQuery(1, func(q *preferencesQuery) {}),
+ Data: getPreferencesQuery(1, func(q *preferencesQuery) {
+ q.All = true // avoid validation error
+ }),
},
},
sqlTeams: {
diff --git a/pkg/registry/apis/preferences/legacy/sql.go b/pkg/registry/apis/preferences/legacy/sql.go
index 845dc3f0bd03c..f87e3edbe80f7 100644
--- a/pkg/registry/apis/preferences/legacy/sql.go
+++ b/pkg/registry/apis/preferences/legacy/sql.go
@@ -9,6 +9,7 @@ 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"
@@ -43,7 +44,6 @@ func NewLegacySQL(db legacysql.LegacyDatabaseProvider) *LegacySQL {
func (s *LegacySQL) listPreferences(ctx context.Context,
ns string, orgId int64,
cb func(req *preferencesQuery) (bool, error),
- access func(p *preferenceModel) bool,
) ([]preferences.Preferences, int64, error) {
var results []preferences.Preferences
var rv sql.NullTime
@@ -65,6 +65,9 @@ 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() {
@@ -99,14 +102,12 @@ func (s *LegacySQL) listPreferences(ctx context.Context,
if pref.Updated.After(rv.Time) {
rv.Time = pref.Updated
}
- if !access(&pref) {
- continue // user does not have access
- }
results = append(results, asPreferencesResource(ns, &pref))
}
if needsRV {
req.Reset()
+ req.All = true // Avoid validation error
q, err = sqltemplate.Execute(sqlPreferencesRV, req)
if err != nil {
return nil, 0, fmt.Errorf("execute template %q: %w", sqlPreferencesRV.Name(), err)
@@ -127,6 +128,7 @@ func (s *LegacySQL) listPreferences(ctx context.Context,
return results, rv.Time.UnixMilli(), err
}
+// Note sending a null user will list all preferences in the namespace!
func (s *LegacySQL) ListPreferences(ctx context.Context, ns string, user identity.Requester, needsRV bool) (*preferences.PreferencesList, error) {
if ns == "" {
return nil, fmt.Errorf("namespace is required")
@@ -137,7 +139,6 @@ func (s *LegacySQL) ListPreferences(ctx context.Context, ns string, user identit
return nil, err
}
- // when the user is nil, it is actually admin and can see everything
var teams []string
found, rv, err := s.listPreferences(ctx, ns, info.OrgID,
func(req *preferencesQuery) (bool, error) {
@@ -148,21 +149,11 @@ func (s *LegacySQL) ListPreferences(ctx context.Context, ns string, user identit
UserUID: req.UserUID,
}, false)
req.UserTeams = teams
+ } else {
+ req.All = true
}
return needsRV, err
},
- func(p *preferenceModel) bool {
- if user == nil || user.GetIsGrafanaAdmin() {
- return true
- }
- if p.UserUID.String != "" {
- return user.GetIdentifier() == p.UserUID.String
- }
- if p.TeamUID.String != "" {
- return slices.Contains(teams, p.TeamUID.String)
- }
- return true
- },
)
if err != nil {
return nil, err
diff --git a/pkg/registry/apis/preferences/legacy/sql_preferences_query.sql b/pkg/registry/apis/preferences/legacy/sql_preferences_query.sql
index 4f88bea3482b6..57e18826d3423 100644
--- a/pkg/registry/apis/preferences/legacy/sql_preferences_query.sql
+++ b/pkg/registry/apis/preferences/legacy/sql_preferences_query.sql
@@ -19,8 +19,10 @@ WHERE p.org_id = {{ .Arg .OrgID }}
{{ if .HasTeams }}
OR t.uid IN ({{ .ArgList .UserTeams }})
{{ end }}
- OR p.user_id = 0
+ OR (p.user_id = 0 AND p.team_id = 0)
{{ end }}
)
+{{ else if not .All }}
+ invalid query -- specify All to list all permissions in query
{{ end }}
ORDER BY p.user_id asc, p.team_id asc, p.org_id asc
diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_preferences_query-current.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_preferences_query-current.sql
index fc9c4244d9570..6e244efd70a94 100755
--- a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_preferences_query-current.sql
+++ b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_preferences_query-current.sql
@@ -13,6 +13,6 @@ SELECT p.id, p.org_id,
WHERE p.org_id = 1
AND (u.uid = 'uuu'
OR t.uid IN ('a', 'b', 'c')
- OR p.user_id = 0
+ OR (p.user_id = 0 AND p.team_id = 0)
)
ORDER BY p.user_id asc, p.team_id asc, p.org_id asc
diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_preferences_query-user-no-teams.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_preferences_query-user-no-teams.sql
index b1a5757d95383..73a25b0929973 100755
--- a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_preferences_query-user-no-teams.sql
+++ b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_preferences_query-user-no-teams.sql
@@ -12,6 +12,6 @@ SELECT p.id, p.org_id,
LEFT JOIN `grafana`.`team` as t ON p.team_id = t.id
WHERE p.org_id = 1
AND (u.uid = 'uuu'
- OR p.user_id = 0
+ OR (p.user_id = 0 AND p.team_id = 0)
)
ORDER BY p.user_id asc, p.team_id asc, p.org_id asc
diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_preferences_query-current.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_preferences_query-current.sql
index fb378535fe04d..b6b07a05162aa 100755
--- a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_preferences_query-current.sql
+++ b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_preferences_query-current.sql
@@ -13,6 +13,6 @@ SELECT p.id, p.org_id,
WHERE p.org_id = 1
AND (u.uid = 'uuu'
OR t.uid IN ('a', 'b', 'c')
- OR p.user_id = 0
+ OR (p.user_id = 0 AND p.team_id = 0)
)
ORDER BY p.user_id asc, p.team_id asc, p.org_id asc
diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_preferences_query-user-no-teams.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_preferences_query-user-no-teams.sql
index f81506ea85329..d9159a963c153 100755
--- a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_preferences_query-user-no-teams.sql
+++ b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_preferences_query-user-no-teams.sql
@@ -12,6 +12,6 @@ SELECT p.id, p.org_id,
LEFT JOIN "grafana"."team" as t ON p.team_id = t.id
WHERE p.org_id = 1
AND (u.uid = 'uuu'
- OR p.user_id = 0
+ OR (p.user_id = 0 AND p.team_id = 0)
)
ORDER BY p.user_id asc, p.team_id asc, p.org_id asc
diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_preferences_query-current.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_preferences_query-current.sql
index fb378535fe04d..b6b07a05162aa 100755
--- a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_preferences_query-current.sql
+++ b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_preferences_query-current.sql
@@ -13,6 +13,6 @@ SELECT p.id, p.org_id,
WHERE p.org_id = 1
AND (u.uid = 'uuu'
OR t.uid IN ('a', 'b', 'c')
- OR p.user_id = 0
+ OR (p.user_id = 0 AND p.team_id = 0)
)
ORDER BY p.user_id asc, p.team_id asc, p.org_id asc
diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_preferences_query-user-no-teams.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_preferences_query-user-no-teams.sql
index f81506ea85329..d9159a963c153 100755
--- a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_preferences_query-user-no-teams.sql
+++ b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_preferences_query-user-no-teams.sql
@@ -12,6 +12,6 @@ SELECT p.id, p.org_id,
LEFT JOIN "grafana"."team" as t ON p.team_id = t.id
WHERE p.org_id = 1
AND (u.uid = 'uuu'
- OR p.user_id = 0
+ OR (p.user_id = 0 AND p.team_id = 0)
)
ORDER BY p.user_id asc, p.team_id asc, p.org_id asc
diff --git a/pkg/storage/unified/sql/sqltemplate/mocks/test_snapshots.go b/pkg/storage/unified/sql/sqltemplate/mocks/test_snapshots.go
index fa1b8c1232759..d024ed880f600 100644
--- a/pkg/storage/unified/sql/sqltemplate/mocks/test_snapshots.go
+++ b/pkg/storage/unified/sql/sqltemplate/mocks/test_snapshots.go
@@ -73,6 +73,9 @@ type TemplateTestCase struct {
// Data should be the struct passed to the template.
Data sqltemplate.SQLTemplate
+
+ // If the query should not validate
+ ValidationError string
}
type TemplateTestSetup struct {
@@ -122,8 +125,12 @@ func CheckQuerySnapshots(t *testing.T, setup TemplateTestSetup) {
// but also not worth deep cloning
input.Data.SetDialect(dialect)
err := input.Data.Validate()
-
+ if input.ValidationError != "" {
+ require.ErrorContains(t, err, input.ValidationError)
+ return
+ }
require.NoError(t, err)
+
got, err := sqltemplate.Execute(tmpl, input.Data)
require.NoError(t, err)