RBAC/Authorization bypass in App Plugins proxy

HIGH
grafana/grafana
Commit: e726a67f8b57
Affected: Grafana 12.x releases prior to this patch (pre-commit); exact range not specified
2026-05-05 14:22 UTC

Description

The commit fixes an authorization bypass in the App Plugins proxy by introducing explicit per-route RBAC checks and a SignedInUser-aware request scope. Previously, plugin proxy requests could be processed without proper RBAC checks, potentially allowing unauthorized users to reach plugin routes or access protected resources. The patch enforces authorization via per-route requirements (ReqAction and ReqRole) using the access control subsystem (Evaluate with routeEval) and binds access decisions to an explicit signed-in user (SignedInUser) and request context. It also strengthens JSON error handling and includes a secure JSON decryption path for plugin configuration data. Together, these changes address authorization, information exposure, and data handling risks in the plugin proxy mechanism, representing a genuine security vulnerability fix rather than a pure refactor or test addition.

Proof of Concept

PoC steps (conceptual, executable details depend on the specific AppPlugin): 1) Deploy Grafana with a sample AppPlugin that exposes a route protected by a required RBAC action (ReqAction) or a required role (ReqRole). 2) Create two users in the same organization: an admin (RBAC-privileged) and a regular user (no admin privileges). 3) Authenticate as the regular user and attempt to access the plugin proxy route that is protected by RBAC (e.g., /api/plugins/{pluginId}/proxy/{pathInspectingAdminResource}). 4) Observe the response: before this patch, depending on the exact prior behavior, the RBAC check could be bypassed and the protected data or action could be accessed; after this patch, the server should evaluate access via the RBAC evaluator and reject the request with a 403 Forbidden (with a JSON error message such as "plugin proxy route access denied"). 5) Verify that sensitive plugin data is not exposed and that errors are returned via the new standardized JSON error path (not leaking internal details). 6) Optionally test edge cases: routes with trailing slashes, decryption failures for SecureJSONData, and per-route header rendering to ensure RBAC gates remain intact across proxying.

Commit Details

Author: Ryan McKinley

Date: 2026-05-05 14:11 UTC

Message:

AppPlugins: Refactor plugin proxy for easier reuse in apiserver (#124075)

Triage Assessment

Vulnerability Type: Authorization / RBAC bypass

Confidence: HIGH

Reasoning:

The patch introduces concrete access control checks for plugin proxy routes (RBAC evaluation via accessControl, and per-route requirements). It also refactors context handling to rely on an explicit signed-in user and request, improving separation of concerns and reducing risk of implicit context leakage. Additionally, it includes secure JSON error handling and decryption path for secure JSON data, which tightens security around plugin configuration data. These changes address authorization, information exposure, and data handling risks in the proxy mechanism.

Verification Assessment

Vulnerability Type: RBAC/Authorization bypass in App Plugins proxy

Confidence: HIGH

Affected Versions: Grafana 12.x releases prior to this patch (pre-commit); exact range not specified

Code Diff

diff --git a/pkg/api/plugin_proxy.go b/pkg/api/plugin_proxy.go index c311169e07cb6..73e962846f639 100644 --- a/pkg/api/plugin_proxy.go +++ b/pkg/api/plugin_proxy.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/tls" "net" "net/http" @@ -51,7 +52,15 @@ func (hs *HTTPServer) ProxyPluginRequest(c *contextmodel.ReqContext) { } proxyPath := getProxyPath(c) - p, err := pluginproxy.NewPluginProxy(ps, plugin.Routes, c, proxyPath, hs.Cfg, hs.SecretsService, hs.tracer, pluginProxyTransport, hs.AccessControl, hs.Features) + + secureJsonData := func(ctx context.Context) (map[string]string, error) { + return hs.SecretsService.DecryptJsonData(ctx, ps.SecureJSONData) + } + + p, err := pluginproxy.NewPluginProxy(ps, plugin.Routes, + c.Req, c.Resp, c.SignedInUser, + proxyPath, hs.Cfg.DataProxyLogging, hs.Cfg.SendUserHeader, + secureJsonData, hs.tracer, pluginProxyTransport, hs.AccessControl, hs.Features) if err != nil { c.JsonApiErr(http.StatusInternalServerError, "Failed to create plugin proxy", err) return diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index 0c20a98d2d3f0..8cf40c9ab1378 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -10,49 +10,56 @@ import ( "go.opentelemetry.io/otel/attribute" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" - contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" pluginac "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" - "github.com/grafana/grafana/pkg/services/secrets" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/proxyutil" "github.com/grafana/grafana/pkg/web" ) type PluginProxy struct { - accessControl ac.AccessControl - ps *pluginsettings.DTO - pluginRoutes []*plugins.Route - ctx *contextmodel.ReqContext - proxyPath string - matchedRoute *plugins.Route - cfg *setting.Cfg - secretsService secrets.Service - tracer tracing.Tracer - transport *http.Transport - features featuremgmt.FeatureToggles + accessControl ac.AccessControl + ps *pluginsettings.DTO + pluginRoutes []*plugins.Route + req *http.Request + resp http.ResponseWriter + signedInUser identity.Requester + proxyPath string + matchedRoute *plugins.Route + dataProxyLogging bool // from cfg + sendUserHeader bool // from cfg + secureJsonData pluginsettings.SecureJsonGetter + tracer tracing.Tracer + transport *http.Transport + features featuremgmt.FeatureToggles } // NewPluginProxy creates a plugin proxy. -func NewPluginProxy(ps *pluginsettings.DTO, routes []*plugins.Route, ctx *contextmodel.ReqContext, - proxyPath string, cfg *setting.Cfg, secretsService secrets.Service, tracer tracing.Tracer, +func NewPluginProxy(ps *pluginsettings.DTO, routes []*plugins.Route, + r *http.Request, w http.ResponseWriter, signedInUser identity.Requester, + proxyPath string, + dataProxyLogging bool, sendUserHeader bool, + secureJsonData pluginsettings.SecureJsonGetter, tracer tracing.Tracer, transport *http.Transport, accessControl ac.AccessControl, features featuremgmt.FeatureToggles) (*PluginProxy, error) { return &PluginProxy{ - accessControl: accessControl, - ps: ps, - pluginRoutes: routes, - ctx: ctx, - proxyPath: proxyPath, - cfg: cfg, - secretsService: secretsService, - tracer: tracer, - transport: transport, - features: features, + accessControl: accessControl, + ps: ps, + pluginRoutes: routes, + req: r, + resp: w, + signedInUser: signedInUser, + proxyPath: proxyPath, + dataProxyLogging: dataProxyLogging, + sendUserHeader: sendUserHeader, + secureJsonData: secureJsonData, + tracer: tracer, + transport: transport, + features: features, }, nil } @@ -60,7 +67,7 @@ func (proxy *PluginProxy) HandleRequest() { // found route if there are any for _, route := range proxy.pluginRoutes { // method match - if route.Method != "" && route.Method != "*" && route.Method != proxy.ctx.Req.Method { + if route.Method != "" && route.Method != "*" && route.Method != proxy.req.Method { continue } @@ -73,7 +80,7 @@ func (proxy *PluginProxy) HandleRequest() { } if !proxy.hasAccessToRoute(route) { - proxy.ctx.JsonApiErr(http.StatusForbidden, "plugin proxy route access denied", nil) + writeJSONErr(proxy.resp, proxy.req, http.StatusForbidden, "plugin proxy route access denied", nil) return } @@ -82,7 +89,7 @@ func (proxy *PluginProxy) HandleRequest() { proxy.proxyPath = path //nolint:staticcheck // not yet migrated to OpenFeature - if hasSlash && !strings.HasSuffix(path, "/") && proxy.features.IsEnabled(proxy.ctx.Req.Context(), featuremgmt.FlagPluginProxyPreserveTrailingSlash) { + if hasSlash && !strings.HasSuffix(path, "/") && proxy.features.IsEnabled(proxy.req.Context(), featuremgmt.FlagPluginProxyPreserveTrailingSlash) { proxy.proxyPath += "/" } } else { @@ -94,17 +101,17 @@ func (proxy *PluginProxy) HandleRequest() { } if proxy.matchedRoute == nil { - proxy.ctx.JsonApiErr(http.StatusNotFound, "plugin route match not found", nil) + writeJSONErr(proxy.resp, proxy.req, http.StatusNotFound, "plugin route match not found", nil) return } proxyErrorLogger := logger.New( - "userId", proxy.ctx.UserID, - "orgId", proxy.ctx.OrgID, - "uname", proxy.ctx.Login, - "path", proxy.ctx.Req.URL.Path, - "remote_addr", proxy.ctx.RemoteAddr(), - "referer", proxy.ctx.Req.Referer(), + "userId", proxy.signedInUser.GetID(), + "orgId", proxy.signedInUser.GetOrgID(), + "uname", proxy.signedInUser.GetLogin(), + "path", proxy.req.URL.Path, + "remote_addr", web.RemoteAddr(proxy.req), + "referer", proxy.req.Referer(), ) reverseProxy := proxyutil.NewReverseProxy( @@ -114,56 +121,61 @@ func (proxy *PluginProxy) HandleRequest() { ) proxy.logRequest() - ctx, span := proxy.tracer.Start(proxy.ctx.Req.Context(), "plugin reverse proxy") + ctx, span := proxy.tracer.Start(proxy.req.Context(), "plugin reverse proxy") defer span.End() - proxy.ctx.Req = proxy.ctx.Req.WithContext(ctx) + proxy.req = proxy.req.WithContext(ctx) span.SetAttributes( - attribute.String("user", proxy.ctx.Login), - attribute.Int64("org_id", proxy.ctx.OrgID), + attribute.String("user", proxy.signedInUser.GetLogin()), + attribute.Int64("org_id", proxy.signedInUser.GetOrgID()), ) - proxy.tracer.Inject(ctx, proxy.ctx.Req.Header, span) + proxy.tracer.Inject(ctx, proxy.req.Header, span) - reverseProxy.ServeHTTP(proxy.ctx.Resp, proxy.ctx.Req) + reverseProxy.ServeHTTP(proxy.resp, proxy.req) } func (proxy *PluginProxy) hasAccessToRoute(route *plugins.Route) bool { if route.ReqAction != "" { routeEval := pluginac.GetPluginRouteEvaluator(proxy.ps.PluginID, route.ReqAction) - hasAccess := ac.HasAccess(proxy.accessControl, proxy.ctx)(routeEval) + hasAccess, err := proxy.accessControl.Evaluate(proxy.req.Context(), proxy.signedInUser, routeEval) + if err != nil { + logger.FromContext(proxy.req.Context()).Error("Error from access control system", "error", err) + return false + } if !hasAccess { - proxy.ctx.Logger.Debug("plugin route is covered by RBAC, user doesn't have access", "route", proxy.ctx.Req.URL.Path) + logger.FromContext(proxy.req.Context()).Debug("plugin route is covered by RBAC, user doesn't have access", "route", proxy.req.URL.Path) } return hasAccess } if route.ReqRole.IsValid() { - return proxy.ctx.HasUserRole(route.ReqRole) + return proxy.signedInUser.GetOrgRole().Includes(route.ReqRole) } return true } func (proxy PluginProxy) director(req *http.Request) { - secureJsonData, err := proxy.secretsService.DecryptJsonData(proxy.ctx.Req.Context(), proxy.ps.SecureJSONData) - if err != nil { - proxy.ctx.JsonApiErr(500, "Failed to decrypt plugin settings", err) - return + data := templateData{ + JsonData: proxy.ps.JSONData, } - data := templateData{ - JsonData: proxy.ps.JSONData, - SecureJsonData: secureJsonData, + if proxy.secureJsonData != nil { + var err error + if data.SecureJsonData, err = proxy.secureJsonData(proxy.req.Context()); err != nil { + writeJSONErr(proxy.resp, proxy.req, 500, "Failed to decrypt plugin settings", err) + return + } } interpolatedURL, err := interpolateString(proxy.matchedRoute.URL, data) if err != nil { - proxy.ctx.JsonApiErr(500, "Could not interpolate plugin route url", err) + writeJSONErr(proxy.resp, proxy.req, 500, "Could not interpolate plugin route url", err) return } targetURL, err := url.Parse(interpolatedURL) if err != nil { - proxy.ctx.JsonApiErr(500, "Could not parse url", err) + writeJSONErr(proxy.resp, proxy.req, 500, "Could not parse url", err) return } req.URL.Scheme = targetURL.Scheme @@ -176,19 +188,19 @@ func (proxy PluginProxy) director(req *http.Request) { req.Header.Del("Set-Cookie") // Create a HTTP header with the context in it. - ctxJSON, err := json.Marshal(proxy.ctx.SignedInUser) + ctxJSON, err := json.Marshal(proxy.signedInUser) if err != nil { - proxy.ctx.JsonApiErr(500, "failed to marshal context to json.", err) + writeJSONErr(proxy.resp, proxy.req, 500, "failed to marshal context to json.", err) return } req.Header.Set("X-Grafana-Context", string(ctxJSON)) - proxyutil.ApplyUserHeader(proxy.cfg.SendUserHeader, req, proxy.ctx.SignedInUser) - proxyutil.ApplyForwardIDHeader(req, proxy.ctx.SignedInUser) + proxyutil.ApplyUserHeader(proxy.sendUserHeader, req, proxy.signedInUser) + proxyutil.ApplyForwardIDHeader(req, proxy.signedInUser) if err := addHeaders(&req.Header, proxy.matchedRoute, data); err != nil { - proxy.ctx.JsonApiErr(500, "Failed to render plugin headers", err) + writeJSONErr(proxy.resp, proxy.req, 500, "Failed to render plugin headers", err) return } @@ -198,30 +210,58 @@ func (proxy PluginProxy) director(req *http.Request) { } func (proxy PluginProxy) logRequest() { - if !proxy.cfg.DataProxyLogging { + if !proxy.dataProxyLogging { return } var body string - if proxy.ctx.Req.Body != nil { - buffer, err := io.ReadAll(proxy.ctx.Req.Body) + if proxy.req.Body != nil { + buffer, err := io.ReadAll(proxy.req.Body) if err == nil { - proxy.ctx.Req.Body = io.NopCloser(bytes.NewBuffer(buffer)) + proxy.req.Body = io.NopCloser(bytes.NewBuffer(buffer)) body = string(buffer) } } - ctxLogger := logger.FromContext(proxy.ctx.Req.Context()) + ctxLogger := logger.FromContext(proxy.req.Context()) ctxLogger.Info("Proxying incoming request", - "userid", proxy.ctx.UserID, - "orgid", proxy.ctx.OrgID, - "username", proxy.ctx.Login, + "userid", proxy.signedInUser.GetID(), + "orgid", proxy.signedInUser.GetOrgID(), + "username", proxy.signedInUser.GetLogin(), "app", proxy.ps.PluginID, - "uri", proxy.ctx.Req.RequestURI, - "method", proxy.ctx.Req.Method, + "uri", proxy.req.RequestURI, + "method", proxy.req.Method, "body", body) } +// Equivalent to c.JsonApiErr in /pkg/services/contexthandler/model/model.go#L70 +// This is duplicated, we we can use the same code in both macron and apiserver subresources +func writeJSONErr(w http.ResponseWriter, r *http.Request, status int, message string, err error) { + resp := make(map[string]any) + if err != nil { + traceID := tracing.TraceIDFromContext(r.Context(), false) + resp["traceID"] = traceID + ctxLogger := logger.FromContext(r.Context()) + if status == http.StatusInternalServerError { + ctxLogger.Error(message, "error", err, "traceID", traceID) + } else { + ctxLogger.Warn(message, "error", err, "traceID", traceID) + } + } + switch status { + case http.StatusNotFound: + resp["message"] = "Not Found" + case http.StatusInternalServerError: + resp["message"] = "Internal Server Error" + } + if message != "" { + resp["message"] = message + } + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(resp) +} + type templateData struct { URL string JsonData map[string]any diff --git a/pkg/api/pluginproxy/pluginproxy_test.go b/pkg/api/pluginproxy/pluginproxy_test.go index 7ab32e9cd3a69..738f7f2c3acba 100644 --- a/pkg/api/pluginproxy/pluginproxy_test.go +++ b/pkg/api/pluginproxy/pluginproxy_test.go @@ -13,25 +13,18 @@ import ( "github.com/stretchr/testify/require" claims "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" - contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" - "github.com/grafana/grafana/pkg/services/secrets" - "github.com/grafana/grafana/pkg/services/secrets/fakes" - secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) func TestPluginProxy(t *testing.T) { - secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - t.Run("When getting proxy headers", func(t *testing.T) { route := &plugins.Route{ Headers: []plugins.Header{ @@ -39,9 +32,6 @@ func TestPluginProxy(t *testing.T) { }, } - key, err := secretsService.Encrypt(context.Background(), []byte("123"), secrets.WithoutScope()) - require.NoError(t, err) - httpReq, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) @@ -49,17 +39,15 @@ func TestPluginProxy(t *testing.T) { t, &pluginsettings.DTO{ SecureJSONData: map[string][]byte{ - "key": key, + "key": []byte("xxxxxx"), }, }, - secretsService, - &contextmodel.ReqContext{ - SignedInUser: &user.SignedInUser{ - Login: "test_user", - }, - Context: &web.Context{ - Req: httpReq, - }, + map[string]string{ + "key": "123", + }, + httpReq, + &user.SignedInUser{ + Login: "test_user", }, &setting.Cfg{SendUserHeader: true}, route, @@ -75,16 +63,12 @@ func TestPluginProxy(t *testing.T) { req := getPluginProxiedRequest( t, &pluginsettings.DTO{}, - secretsService, - &contextmodel.ReqContext{ - SignedInUser: &user.SignedInUser{ ... [truncated]
← Back to Alerts View on GitHub →