package pluginproxy import ( "context" "fmt" "io" "net/http" "net/http/httptest" "net/url" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "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{ {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, }, } 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) req := getPluginProxiedRequest( t, &pluginsettings.DTO{ SecureJSONData: map[string][]byte{ "key": key, }, }, secretsService, &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, Context: &web.Context{ Req: httpReq, }, }, &setting.Cfg{SendUserHeader: true}, route, ) assert.Equal(t, "my secret 123", req.Header.Get("x-header")) }) t.Run("When SendUserHeader config is enabled", func(t *testing.T) { httpReq, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) req := getPluginProxiedRequest( t, &pluginsettings.DTO{}, secretsService, &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", NamespacedID: "user:1", }, Context: &web.Context{ Req: httpReq, }, }, &setting.Cfg{SendUserHeader: true}, nil, ) // Get will return empty string even if header is not set assert.Equal(t, "test_user", req.Header.Get("X-Grafana-User")) }) t.Run("When SendUserHeader config is disabled", func(t *testing.T) { httpReq, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) req := getPluginProxiedRequest( t, &pluginsettings.DTO{}, secretsService, &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, Context: &web.Context{ Req: httpReq, }, }, &setting.Cfg{SendUserHeader: false}, nil, ) // Get will return empty string even if header is not set assert.Equal(t, "", req.Header.Get("X-Grafana-User")) }) t.Run("When SendUserHeader config is enabled but user is anonymous", func(t *testing.T) { httpReq, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) req := getPluginProxiedRequest( t, &pluginsettings.DTO{}, secretsService, &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{IsAnonymous: true}, Context: &web.Context{ Req: httpReq, }, }, &setting.Cfg{SendUserHeader: true}, nil, ) // Get will return empty string even if header is not set assert.Equal(t, "", req.Header.Get("X-Grafana-User")) }) t.Run("When getting templated url", func(t *testing.T) { route := &plugins.Route{ URL: "{{.JsonData.dynamicUrl}}", Method: "GET", } httpReq, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) req := getPluginProxiedRequest( t, &pluginsettings.DTO{ JSONData: map[string]any{ "dynamicUrl": "https://dynamic.grafana.com", }, }, secretsService, &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, Context: &web.Context{ Req: httpReq, }, }, &setting.Cfg{SendUserHeader: true}, route, ) assert.Equal(t, "https://dynamic.grafana.com", req.URL.String()) assert.Equal(t, "{{.JsonData.dynamicUrl}}", route.URL) }) t.Run("When getting complex templated url", func(t *testing.T) { route := &plugins.Route{ URL: "{{if .JsonData.apiHost}}{{.JsonData.apiHost}}{{else}}https://example.com{{end}}", Method: "GET", } httpReq, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) req := getPluginProxiedRequest( t, &pluginsettings.DTO{}, secretsService, &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, Context: &web.Context{ Req: httpReq, }, }, &setting.Cfg{SendUserHeader: true}, route, ) assert.Equal(t, "https://example.com", req.URL.String()) }) t.Run("When getting templated body", func(t *testing.T) { route := &plugins.Route{ Path: "api/body", URL: "http://www.test.com", Body: []byte(`{ "url": "{{.JsonData.dynamicUrl}}", "secret": "{{.SecureJsonData.key}}" }`), } encryptedJsonData, err := secretsService.EncryptJsonData( context.Background(), map[string]string{"key": "123"}, secrets.WithoutScope(), ) require.NoError(t, err) httpReq, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) req := getPluginProxiedRequest( t, &pluginsettings.DTO{ JSONData: map[string]any{"dynamicUrl": "https://dynamic.grafana.com"}, SecureJSONData: encryptedJsonData, }, secretsService, &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{ Login: "test_user", }, Context: &web.Context{ Req: httpReq, }, }, &setting.Cfg{SendUserHeader: true}, route, ) content, err := io.ReadAll(req.Body) require.NoError(t, err) require.Equal(t, `{ "url": "https://dynamic.grafana.com", "secret": "123" }`, string(content)) }) t.Run("When proxying a request should set expected response headers", func(t *testing.T) { requestHandled := false backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) _, _ = w.Write([]byte("I am the backend")) requestHandled = true })) t.Cleanup(backendServer.Close) responseWriter := web.NewResponseWriter("GET", httptest.NewRecorder()) routes := []*plugins.Route{ { Path: "/", URL: backendServer.URL, }, } ctx := &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{}, Context: &web.Context{ Req: httptest.NewRequest("GET", "/", nil), Resp: responseWriter, }, } ps := &pluginsettings.DTO{ SecureJSONData: map[string][]byte{}, } cfg := &setting.Cfg{} proxy, err := NewPluginProxy(ps, routes, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(cfg), featuremgmt.WithFeatures()) require.NoError(t, err) proxy.HandleRequest() for { if requestHandled { break } } require.Equal(t, "sandbox", ctx.Resp.Header().Get("Content-Security-Policy")) }) } func TestPluginProxyRoutes(t *testing.T) { routes := []*plugins.Route{ { Path: "", Method: "GET", URL: "http://localhost", }, { Path: "some-api", Method: "GET", URL: "http://localhost/api", }, { Path: "some-api/instances", Method: "GET", URL: "http://localhost/api/instances/", }, { Path: "some-api/*", Method: "GET", URL: "http://localhost/api", }, { Path: "some-api/instances/*", Method: "GET", URL: "http://localhost/api/instances", }, { Path: "some-other-api/*", Method: "GET", URL: "http://localhost/api/v2", }, { Path: "some-other-api/instances/*", Method: "GET", URL: "http://localhost/api/v2/instances", }, } tcs := []struct { proxyPath string expectedURLPath string expectedStatus int }{ { proxyPath: "/notexists", expectedStatus: http.StatusNotFound, }, { proxyPath: "/", expectedURLPath: "/", expectedStatus: http.StatusOK, }, { proxyPath: "/some-api", expectedURLPath: "/api", expectedStatus: http.StatusOK, }, { proxyPath: "/some-api/instances", expectedURLPath: "/api/instances/", expectedStatus: http.StatusOK, }, { proxyPath: "/some-api/some/thing", expectedURLPath: "/api/some/thing", expectedStatus: http.StatusOK, }, { proxyPath: "/some-api/instances/instance-one", expectedURLPath: "/api/instances/instance-one", expectedStatus: http.StatusOK, }, { proxyPath: "/some-other-api/some/thing", expectedURLPath: "/api/v2/some/thing", expectedStatus: http.StatusOK, }, { proxyPath: "/some-other-api/instances/instance-one", expectedURLPath: "/api/v2/instances/instance-one", expectedStatus: http.StatusOK, }, } for _, tc := range tcs { t.Run(fmt.Sprintf("When proxying path %q should call expected URL", tc.proxyPath), func(t *testing.T) { secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) requestHandled := false requestURL := "" backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestURL = r.URL.RequestURI() w.WriteHeader(200) _, _ = w.Write([]byte("I am the backend")) requestHandled = true })) t.Cleanup(backendServer.Close) backendURL, err := url.Parse(backendServer.URL) require.NoError(t, err) testRoutes := make([]*plugins.Route, len(routes)) for i, r := range routes { u, err := url.Parse(r.URL) require.NoError(t, err) u.Scheme = backendURL.Scheme u.Host = backendURL.Host testRoute := *r testRoute.URL = u.String() testRoutes[i] = &testRoute } responseWriter := web.NewResponseWriter("GET", httptest.NewRecorder()) ctx := &contextmodel.ReqContext{ SignedInUser: &user.SignedInUser{}, Context: &web.Context{ Req: httptest.NewRequest("GET", tc.proxyPath, nil), Resp: responseWriter, }, } ps := &pluginsettings.DTO{ SecureJSONData: map[string][]byte{}, } cfg := &setting.Cfg{} proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(cfg), featuremgmt.WithFeatures()) require.NoError(t, err) proxy.HandleRequest() for { if requestHandled || ctx.Resp.Written() { break } } require.Equal(t, tc.expectedStatus, ctx.Resp.Status()) if tc.expectedStatus == http.StatusNotFound { return } require.Equal(t, tc.expectedURLPath, requestURL) }) } } func TestPluginProxyRoutesAccessControl(t *testing.T) { routes := []*plugins.Route{ { Path: "settings", Method: "GET", URL: "http://localhost/api/settings", ReqRole: org.RoleAdmin, // Protected by role }, { Path: "projects", Method: "GET", URL: "http://localhost/api/projects", ReqAction: "plugin-id.projects:read", // Protected by RBAC action }, } tcs := []struct { proxyPath string usrRole org.RoleType usrPerms map[string][]string expectedURLPath string expectedStatus int }{ { proxyPath: "/settings", usrRole: org.RoleAdmin, expectedURLPath: "/api/settings", expectedStatus: http.StatusOK, }, { proxyPath: "/settings", usrRole: org.RoleViewer, expectedURLPath: "/api/settings", expectedStatus: http.StatusForbidden, }, { proxyPath: "/projects", usrPerms: map[string][]string{"plugin-id.projects:read": {}}, expectedURLPath: "/api/projects", expectedStatus: http.StatusOK, }, { proxyPath: "/projects", usrPerms: map[string][]string{}, expectedURLPath: "/api/projects", expectedStatus: http.StatusForbidden, }, } for _, tc := range tcs { t.Run(fmt.Sprintf("Should enforce RBAC when proxying path %s %s", tc.proxyPath, http.StatusText(tc.expectedStatus)), func(t *testing.T) { secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) requestHandled := false requestURL := "" backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestURL = r.URL.RequestURI() w.WriteHeader(200) _, _ = w.Write([]byte("I am the backend")) requestHandled = true })) t.Cleanup(backendServer.Close) backendURL, err := url.Parse(backendServer.URL) require.NoError(t, err) testRoutes := make([]*plugins.Route, len(routes)) for i, r := range routes { u, err := url.Parse(r.URL) require.NoError(t, err) u.Scheme = backendURL.Scheme u.Host = backendURL.Host testRoute := *r testRoute.URL = u.String() testRoutes[i] = &testRoute } responseWriter := web.NewResponseWriter("GET", httptest.NewRecorder()) ctx := &contextmodel.ReqContext{ Logger: logger.New("pluginproxy-test"), SignedInUser: &user.SignedInUser{ OrgID: 1, OrgRole: tc.usrRole, Permissions: map[int64]map[string][]string{1: tc.usrPerms}, }, Context: &web.Context{ Req: httptest.NewRequest("GET", tc.proxyPath, nil), Resp: responseWriter, }, } ps := &pluginsettings.DTO{ SecureJSONData: map[string][]byte{}, } cfg := &setting.Cfg{} proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(cfg), featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall)) require.NoError(t, err) proxy.HandleRequest() for { if requestHandled || ctx.Resp.Written() { break } } require.Equal(t, tc.expectedStatus, ctx.Resp.Status()) if tc.expectedStatus == http.StatusForbidden { return } require.Equal(t, tc.expectedURLPath, requestURL) }) } } // getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. func getPluginProxiedRequest(t *testing.T, ps *pluginsettings.DTO, secretsService secrets.Service, ctx *contextmodel.ReqContext, cfg *setting.Cfg, route *plugins.Route) *http.Request { // insert dummy route if none is specified if route == nil { route = &plugins.Route{ Path: "api/v4/", URL: "https://www.google.com", ReqRole: org.RoleEditor, } } proxy, err := NewPluginProxy(ps, []*plugins.Route{}, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(cfg), featuremgmt.WithFeatures()) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "/api/plugin-proxy/grafana-simple-app/api/v4/alerts", nil) require.NoError(t, err) proxy.matchedRoute = route proxy.director(req) return req }