diff --git a/docs/sources/http_api/team.md b/docs/sources/http_api/team.md index d9d0135fdc6..f530a6f2cf4 100644 --- a/docs/sources/http_api/team.md +++ b/docs/sources/http_api/team.md @@ -7,7 +7,13 @@ aliases = ["/docs/grafana/latest/http_api/team/"] # Team API -This API can be used to create/update/delete Teams and to add/remove users to Teams. All actions require that the user has the Admin role for the organization. +This API can be used to manage Teams and Team Memberships. + +Access to these API endpoints is restricted as follows: + +- All authenticated users are able to view details of teams they are a member of. +- Organization Admins are able to manage all teams and team members. +- If the `editors_can_admin` configuration flag is enabled, Organization Editors are able to view details of all teams and to manage teams that they are Admin members of. ## Team Search With Paging diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index 31c43653ff5..3778c8db92c 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -288,6 +288,7 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.UserId = testUserID sc.context.OrgId = testOrgID @@ -346,6 +347,7 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou sc.userAuthTokenService = fakeAuthTokenService sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.UserId = testUserID sc.context.OrgId = testOrgID @@ -464,6 +466,7 @@ func adminCreateUserScenario(t *testing.T, desc string, url string, routePattern sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.UserId = testUserID diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go index b484c8337ca..0e14648d080 100644 --- a/pkg/api/alerting_test.go +++ b/pkg/api/alerting_test.go @@ -144,6 +144,7 @@ func postAlertScenario(t *testing.T, hs *HTTPServer, desc string, url string, ro sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.UserId = testUserID sc.context.OrgId = testOrgID diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index f51b3349d55..365253d4016 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -279,6 +279,7 @@ func postAnnotationScenario(t *testing.T, desc string, url string, routePattern sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.UserId = testUserID sc.context.OrgId = testOrgID @@ -304,6 +305,7 @@ func putAnnotationScenario(t *testing.T, desc string, url string, routePattern s sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.UserId = testUserID sc.context.OrgId = testOrgID @@ -328,6 +330,7 @@ func patchAnnotationScenario(t *testing.T, desc string, url string, routePattern sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.UserId = testUserID sc.context.OrgId = testOrgID @@ -353,6 +356,7 @@ func deleteAnnotationsScenario(t *testing.T, desc string, url string, routePatte sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.UserId = testUserID sc.context.OrgId = testOrgID diff --git a/pkg/api/api.go b/pkg/api/api.go index efe96c92e30..f0d04909396 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -27,7 +27,7 @@ func (hs *HTTPServer) registerRoutes() { reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin reqOrgAdminFolderAdminOrTeamAdmin := middleware.OrgAdminFolderAdminOrTeamAdmin - reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin) + reqCanAccessTeams := middleware.AdminOrEditorAndFeatureEnabled(hs.Cfg.EditorsCanAdmin) reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg) redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg) authorize := acmiddleware.Middleware(hs.AccessControl) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 0212a34e748..2d8884e403f 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -102,6 +102,7 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { sc.resp = httptest.NewRecorder() req, err := http.NewRequest(method, url, nil) require.NoError(sc.t, err) + req.Header.Add("Content-Type", "application/json") sc.req = req return sc @@ -117,6 +118,8 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map panic(fmt.Sprintf("Making request failed: %s", err)) } + req.Header.Add("Content-Type", "application/json") + q := req.URL.Query() for k, v := range queryParams { q.Add(k, v) @@ -129,6 +132,7 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map func (sc *scenarioContext) fakeReqNoAssertions(method, url string) *scenarioContext { sc.resp = httptest.NewRecorder() req, _ := http.NewRequest(method, url, nil) + req.Header.Add("Content-Type", "application/json") sc.req = req return sc @@ -140,7 +144,7 @@ func (sc *scenarioContext) fakeReqNoAssertionsWithCookie(method, url string, coo req, _ := http.NewRequest(method, url, nil) req.Header = http.Header{"Cookie": sc.resp.Header()["Set-Cookie"]} - + req.Header.Add("Content-Type", "application/json") sc.req = req return sc diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go index 63daaf14ba3..f5dd0f490b0 100644 --- a/pkg/api/dashboard_permission_test.go +++ b/pkg/api/dashboard_permission_test.go @@ -383,6 +383,7 @@ func updateDashboardPermissionScenario(t *testing.T, ctx updatePermissionContext sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(ctx.cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.OrgId = testOrgID sc.context.UserId = testUserID diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index a5f109a0de5..34126c6f908 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -36,6 +36,7 @@ import ( func TestGetHomeDashboard(t *testing.T) { httpReq, err := http.NewRequest(http.MethodGet, "", nil) require.NoError(t, err) + httpReq.Header.Add("Content-Type", "application/json") req := &models.ReqContext{SignedInUser: &models.SignedInUser{}, Context: &web.Context{Req: httpReq}} cfg := setting.NewCfg() cfg.StaticRootPath = "../../public/" @@ -1023,6 +1024,7 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.SignedInUser = &models.SignedInUser{OrgId: cmd.OrgId, UserId: cmd.UserId} @@ -1056,6 +1058,7 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.SignedInUser = &models.SignedInUser{ OrgId: testOrgID, @@ -1095,6 +1098,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout sc.sqlStore = mockSQLStore sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.SignedInUser = &models.SignedInUser{ OrgId: testOrgID, diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go index a784b76e6b5..395e4a58f2a 100644 --- a/pkg/api/folder_permission_test.go +++ b/pkg/api/folder_permission_test.go @@ -405,6 +405,7 @@ func updateFolderPermissionScenario(t *testing.T, ctx updatePermissionContext, h sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(ctx.cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.OrgId = testOrgID sc.context.UserId = testUserID diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 1a167304ecc..08ae3853a3b 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -149,6 +149,7 @@ func createFolderScenario(t *testing.T, desc string, url string, routePattern st sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.SignedInUser = &models.SignedInUser{OrgId: testOrgID, UserId: testUserID} @@ -184,6 +185,7 @@ func updateFolderScenario(t *testing.T, desc string, url string, routePattern st sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.SignedInUser = &models.SignedInUser{OrgId: testOrgID, UserId: testUserID} diff --git a/pkg/api/frontend_logging_test.go b/pkg/api/frontend_logging_test.go index 3658645372a..748800236b6 100644 --- a/pkg/api/frontend_logging_test.go +++ b/pkg/api/frontend_logging_test.go @@ -92,6 +92,7 @@ func logSentryEventScenario(t *testing.T, desc string, event frontendlogging.Fro handler := routing.Wrap(func(c *models.ReqContext) response.Response { sc.context = c c.Req.Body = mockRequestBody(event) + c.Req.Header.Add("Content-Type", "application/json") return loggingHandler(c) }) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 31dd4e6a561..5c666b98022 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -444,6 +444,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { } m.Use(middleware.Recovery(hs.Cfg)) + m.UseMiddleware(middleware.CSRF(hs.Cfg.LoginCookieName)) hs.mapStatic(m, hs.Cfg.StaticRootPath, "build", "public/build") hs.mapStatic(m, hs.Cfg.StaticRootPath, "", "public", "/public/views/swagger.html") diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index cb9564fa183..f7b10b1c76b 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -56,6 +56,7 @@ func (t *handleResponseTransport) RoundTrip(req *http.Request) (*http.Response, return nil, err } res.Header.Del("Set-Cookie") + proxyutil.SetProxyResponseHeaders(res.Header) return res, nil } diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 729aa365306..084db92035b 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -670,6 +670,20 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { assert.Equal(t, "important_cookie=important_value", proxy.ctx.Resp.Header().Get("Set-Cookie")) }) + t.Run("When response should set Content-Security-Policy header", func(t *testing.T) { + ctx, ds := setUp(t) + var routes []*plugins.Route + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + dsService := datasources.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) + require.NoError(t, err) + + proxy.HandleRequest() + + require.NoError(t, writeErr) + assert.Equal(t, "sandbox", proxy.ctx.Resp.Header().Get("Content-Security-Policy")) + }) + t.Run("Data source returns status code 401", func(t *testing.T) { ctx, ds := setUp(t, setUpCfg{ writeCb: func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index cfbaffc832b..b22b9f1dbe2 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -83,5 +83,11 @@ func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins. } } - return &httputil.ReverseProxy{Director: director} + return &httputil.ReverseProxy{Director: director, ModifyResponse: modifyResponse} +} + +func modifyResponse(resp *http.Response) error { + proxyutil.SetProxyResponseHeaders(resp.Header) + + return nil } diff --git a/pkg/api/pluginproxy/pluginproxy_test.go b/pkg/api/pluginproxy/pluginproxy_test.go index ff862606566..45d6fe4d440 100644 --- a/pkg/api/pluginproxy/pluginproxy_test.go +++ b/pkg/api/pluginproxy/pluginproxy_test.go @@ -4,6 +4,7 @@ import ( "context" "io/ioutil" "net/http" + "net/http/httptest" "testing" "github.com/grafana/grafana/pkg/models" @@ -238,6 +239,46 @@ func TestPluginProxy(t *testing.T) { 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()) + + route := &plugins.Route{ + Path: "/", + URL: backendServer.URL, + } + + ctx := &models.ReqContext{ + SignedInUser: &models.SignedInUser{}, + Context: &web.Context{ + Req: httptest.NewRequest("GET", "/", nil), + Resp: responseWriter, + }, + } + store := mockstore.NewSQLStoreMock() + + store.ExpectedPluginSetting = &models.PluginSetting{ + SecureJsonData: map[string][]byte{}, + } + proxy := NewApiPluginProxy(ctx, "", route, "", &setting.Cfg{}, store, secretsService) + proxy.ServeHTTP(ctx.Resp, ctx.Req) + + for { + if requestHandled { + break + } + } + + require.Equal(t, "sandbox", ctx.Resp.Header().Get("Content-Security-Policy")) + }) } // getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 0a427d9135e..3744affae29 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -613,6 +613,9 @@ func (hs *HTTPServer) flushStream(stream callResourceClientResponseStream, w htt w.Header().Add(k, v) } } + + proxyutil.SetProxyResponseHeaders(w.Header()) + w.WriteHeader(resp.Status) } diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index fb10aa0e4d3..8c8674fd6c4 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -1,9 +1,12 @@ package api import ( + "context" "encoding/json" "fmt" "io/ioutil" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -11,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" @@ -185,6 +189,28 @@ func Test_GetPluginAssets(t *testing.T) { }) } +func TestMakePluginResourceRequest(t *testing.T) { + pluginClient := &fakePluginClient{} + hs := HTTPServer{ + Cfg: setting.NewCfg(), + log: log.New(), + pluginClient: pluginClient, + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + resp := httptest.NewRecorder() + pCtx := backend.PluginContext{} + err := hs.makePluginResourceRequest(resp, req, pCtx) + require.NoError(t, err) + + for { + if resp.Flushed { + break + } + } + + require.Equal(t, "sandbox", resp.Header().Get("Content-Security-Policy")) +} + func callGetPluginAsset(sc *scenarioContext) { sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } @@ -221,3 +247,25 @@ type logger struct { func (l *logger) Warn(msg string, ctx ...interface{}) { l.warnings = append(l.warnings, msg) } + +type fakePluginClient struct { + plugins.Client + + req *backend.CallResourceRequest +} + +func (c *fakePluginClient) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + c.req = req + bytes, err := json.Marshal(map[string]interface{}{ + "message": "hello", + }) + if err != nil { + return err + } + + return sender.Send(&backend.CallResourceResponse{ + Status: http.StatusOK, + Headers: make(map[string][]string), + Body: bytes, + }) +} diff --git a/pkg/api/short_url_test.go b/pkg/api/short_url_test.go index ba95fb2c4b9..752004738f2 100644 --- a/pkg/api/short_url_test.go +++ b/pkg/api/short_url_test.go @@ -65,6 +65,7 @@ func createShortURLScenario(t *testing.T, desc string, url string, routePattern sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { c.Req.Body = mockRequestBody(cmd) + c.Req.Header.Add("Content-Type", "application/json") sc.context = c sc.context.SignedInUser = &models.SignedInUser{OrgId: testOrgID, UserId: testUserID} diff --git a/pkg/api/team.go b/pkg/api/team.go index b880bb4ba2e..305ef661022 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -130,16 +130,11 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { page = 1 } - var userIdFilter int64 - if hs.Cfg.EditorsCanAdmin && c.OrgRole != models.ROLE_ADMIN { - userIdFilter = c.SignedInUser.UserId - } - query := models.SearchTeamsQuery{ OrgId: c.OrgId, Query: c.Query("query"), Name: c.Query("name"), - UserIdFilter: userIdFilter, + UserIdFilter: userFilter(hs.Cfg.EditorsCanAdmin, c), Page: page, Limit: perPage, SignedInUser: c.SignedInUser, @@ -186,17 +181,32 @@ func (hs *HTTPServer) getTeamAccessControlMetadata(c *models.ReqContext, teamID return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "teams", teamIDs)[key], nil } +// UserFilter returns the user ID used in a filter when querying a team +// 1. If the user is a viewer or editor, this will return the user's ID. +// 2. If EditorsCanAdmin is enabled and the user is an editor, this will return models.FilterIgnoreUser (0) +// 3. If the user is an admin, this will return models.FilterIgnoreUser (0) +func userFilter(editorsCanAdmin bool, c *models.ReqContext) int64 { + userIdFilter := c.SignedInUser.UserId + if (editorsCanAdmin && c.OrgRole == models.ROLE_EDITOR) || c.OrgRole == models.ROLE_ADMIN { + userIdFilter = models.FilterIgnoreUser + } + + return userIdFilter +} + // GET /api/teams/:teamId func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response { teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "teamId is invalid", err) } + query := models.GetTeamByIdQuery{ OrgId: c.OrgId, Id: teamId, SignedInUser: c.SignedInUser, HiddenUsers: hs.Cfg.HiddenUsers, + UserIdFilter: userFilter(hs.Cfg.EditorsCanAdmin, c), } if err := hs.SQLStore.GetTeamById(c.Req.Context(), &query); err != nil { diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 3c094622086..bdbe6bf1cc3 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -25,6 +25,9 @@ func (hs *HTTPServer) GetTeamMembers(c *models.ReqContext) response.Response { } query := models.GetTeamMembersQuery{OrgId: c.OrgId, TeamId: teamId} + if err := hs.teamGuardian.CanAdmin(c.Req.Context(), query.OrgId, query.TeamId, c.SignedInUser); err != nil { + return response.Error(403, "Not allowed to list team members", err) + } if err := hs.SQLStore.GetTeamMembers(c.Req.Context(), &query); err != nil { return response.Error(500, "Failed to get Team Members", err) diff --git a/pkg/api/team_members_test.go b/pkg/api/team_members_test.go index 8dba3bc7335..96d10e33d88 100644 --- a/pkg/api/team_members_test.go +++ b/pkg/api/team_members_test.go @@ -21,6 +21,14 @@ import ( "github.com/stretchr/testify/require" ) +type TeamGuardianMock struct { + result error +} + +func (t *TeamGuardianMock) CanAdmin(ctx context.Context, orgId int64, teamId int64, user *models.SignedInUser) error { + return t.result +} + func setUpGetTeamMembersHandler(t *testing.T, sqlStore *sqlstore.SQLStore) { const testOrgID int64 = 1 var userCmd models.CreateUserCommand @@ -44,9 +52,10 @@ func TestTeamMembersAPIEndpoint_userLoggedIn(t *testing.T) { settings := setting.NewCfg() sqlStore := sqlstore.InitTestDB(t) hs := &HTTPServer{ - Cfg: settings, - License: &licensing.OSSLicensingService{}, - SQLStore: sqlStore, + Cfg: settings, + License: &licensing.OSSLicensingService{}, + SQLStore: sqlStore, + teamGuardian: &TeamGuardianMock{}, } mock := mockstore.NewSQLStoreMock() diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index 2f5f997da21..3f01174fa30 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -36,6 +36,7 @@ func TestTeamAPIEndpoint(t *testing.T) { hs := setupSimpleHTTPServer(nil) hs.SQLStore = sqlstore.InitTestDB(t) mock := &mockstore.SQLStoreMock{} + hs.Cfg.EditorsCanAdmin = true loggedInUserScenario(t, "When calling GET on", "/api/teams/search", "/api/teams/search", func(sc *scenarioContext) { _, err := hs.SQLStore.CreateTeam("team1", "", 1) require.NoError(t, err) @@ -96,6 +97,7 @@ func TestTeamAPIEndpoint(t *testing.T) { } c.OrgRole = models.ROLE_EDITOR c.Req.Body = mockRequestBody(models.CreateTeamCommand{Name: teamName}) + c.Req.Header.Add("Content-Type", "application/json") r := hs.CreateTeam(c) assert.Equal(t, 200, r.Status()) @@ -112,6 +114,7 @@ func TestTeamAPIEndpoint(t *testing.T) { } c.OrgRole = models.ROLE_EDITOR c.Req.Body = mockRequestBody(models.CreateTeamCommand{Name: teamName}) + c.Req.Header.Add("Content-Type", "application/json") r := hs.CreateTeam(c) assert.Equal(t, 200, r.Status()) assert.False(t, stub.warnCalled) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 08e60936e5f..a3ff1276639 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -128,20 +128,22 @@ func Auth(options *AuthOptions) web.Handler { } } -// AdminOrFeatureEnabled creates a middleware that allows access -// if the signed in user is either an Org Admin or if the -// feature flag is enabled. +// AdminOrEditorAndFeatureEnabled creates a middleware that allows +// access if the signed in user is either an Org Admin or if they +// are an Org Editor and the feature flag is enabled. // Intended for when feature flags open up access to APIs that // are otherwise only available to admins. -func AdminOrFeatureEnabled(enabled bool) web.Handler { +func AdminOrEditorAndFeatureEnabled(enabled bool) web.Handler { return func(c *models.ReqContext) { if c.OrgRole == models.ROLE_ADMIN { return } - if !enabled { - accessForbidden(c) + if c.OrgRole == models.ROLE_EDITOR && enabled { + return } + + accessForbidden(c) } } diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go new file mode 100644 index 00000000000..bc70d09779d --- /dev/null +++ b/pkg/middleware/csrf.go @@ -0,0 +1,39 @@ +package middleware + +import ( + "errors" + "net/http" + "net/url" + "strings" +) + +func CSRF(loginCookieName string) func(http.Handler) http.Handler { + // As per RFC 7231/4.2.2 these methods are idempotent: + // (GET is excluded because it may have side effects in some APIs) + safeMethods := []string{"HEAD", "OPTIONS", "TRACE"} + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // If request has no login cookie - skip CSRF checks + if _, err := r.Cookie(loginCookieName); errors.Is(err, http.ErrNoCookie) { + next.ServeHTTP(w, r) + return + } + // Skip CSRF checks for "safe" methods + for _, method := range safeMethods { + if r.Method == method { + next.ServeHTTP(w, r) + return + } + } + // Otherwise - verify that Origin matches the server origin + host := strings.Split(r.Host, ":")[0] + origin, err := url.Parse(r.Header.Get("Origin")) + if err != nil || (origin.String() != "" && origin.Hostname() != host) { + http.Error(w, "origin not allowed", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/pkg/models/team.go b/pkg/models/team.go index 38f434458af..567798869da 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -55,8 +55,12 @@ type GetTeamByIdQuery struct { SignedInUser *SignedInUser HiddenUsers map[string]struct{} Result *TeamDTO + UserIdFilter int64 } +// FilterIgnoreUser is used in a get / search teams query when the caller does not want to filter teams by user ID / membership +const FilterIgnoreUser int64 = 0 + type GetTeamsByUserQuery struct { OrgId int64 UserId int64 `json:"userId"` diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go index 29b24197933..857811da650 100644 --- a/pkg/services/dashboardimport/api/api_test.go +++ b/pkg/services/dashboardimport/api/api_test.go @@ -43,6 +43,7 @@ func TestImportDashboardAPI(t *testing.T) { jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) + req.Header.Add("Content-Type", "application/json") resp, err := s.Send(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) @@ -57,6 +58,7 @@ func TestImportDashboardAPI(t *testing.T) { jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) + req.Header.Add("Content-Type", "application/json") webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) @@ -73,6 +75,7 @@ func TestImportDashboardAPI(t *testing.T) { jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) + req.Header.Add("Content-Type", "application/json") webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) @@ -90,6 +93,7 @@ func TestImportDashboardAPI(t *testing.T) { jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) + req.Header.Add("Content-Type", "application/json") webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) @@ -132,6 +136,7 @@ func TestImportDashboardAPI(t *testing.T) { jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) + req.Header.Add("Content-Type", "application/json") webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) @@ -160,6 +165,7 @@ func TestImportDashboardAPI(t *testing.T) { jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) + req.Header.Add("Content-Type", "application/json") webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 2648496a108..5744f9c8b5e 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -289,7 +289,11 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo t.Helper() t.Run(desc, func(t *testing.T) { - ctx := web.Context{Req: &http.Request{}} + ctx := web.Context{Req: &http.Request{ + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }} orgID := int64(1) role := models.ROLE_ADMIN sqlStore := sqlstore.InitTestDB(t) diff --git a/pkg/services/queryhistory/queryhistory_test.go b/pkg/services/queryhistory/queryhistory_test.go index 5319685e9c2..faae8d27411 100644 --- a/pkg/services/queryhistory/queryhistory_test.go +++ b/pkg/services/queryhistory/queryhistory_test.go @@ -35,7 +35,10 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo t.Helper() t.Run(desc, func(t *testing.T) { - ctx := web.Context{Req: &http.Request{}} + ctx := web.Context{Req: &http.Request{ + Header: http.Header{}, + }} + ctx.Req.Header.Add("Content-Type", "application/json") sqlStore := sqlstore.InitTestDB(t) service := QueryHistoryService{ Cfg: setting.NewCfg(), diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index ebb176816c1..8f59ad0c875 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -113,6 +113,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { var requestResponse = func(server *web.Mux, httpMethod, requestpath string, requestBody io.Reader) *httptest.ResponseRecorder { req, err := http.NewRequest(httpMethod, requestpath, requestBody) require.NoError(t, err) + req.Header.Add("Content-Type", "application/json") recorder := httptest.NewRecorder() server.ServeHTTP(recorder, req) return recorder @@ -206,6 +207,7 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) { var requestResponse = func(server *web.Mux, httpMethod, requestpath string, requestBody io.Reader) *httptest.ResponseRecorder { req, err := http.NewRequest(httpMethod, requestpath, requestBody) require.NoError(t, err) + req.Header.Add("Content-Type", "application/json") recorder := httptest.NewRecorder() server.ServeHTTP(recorder, req) return recorder diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 5dd220e251a..67dd3ca18f7 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -64,18 +64,6 @@ func getTeamMemberCount(filteredUsers []string) string { return "(SELECT COUNT(*) FROM team_member WHERE team_member.team_id = team.id) AS member_count " } -func getTeamSearchSQLBase(filteredUsers []string) string { - return `SELECT - team.id AS id, - team.org_id, - team.name AS name, - team.email AS email, - team_member.permission, ` + - getTeamMemberCount(filteredUsers) + - ` FROM team AS team - INNER JOIN team_member ON team.id = team_member.team_id AND team_member.user_id = ? ` -} - func getTeamSelectSQLBase(filteredUsers []string) string { return `SELECT team.id as id, @@ -198,17 +186,15 @@ func (ss *SQLStore) SearchTeams(ctx context.Context, query *models.SearchTeamsQu params := make([]interface{}, 0) filteredUsers := getFilteredUsers(query.SignedInUser, query.HiddenUsers) - if query.UserIdFilter > 0 { - sql.WriteString(getTeamSearchSQLBase(filteredUsers)) - for _, user := range filteredUsers { - params = append(params, user) - } + sql.WriteString(getTeamSelectSQLBase(filteredUsers)) + + for _, user := range filteredUsers { + params = append(params, user) + } + + if query.UserIdFilter != models.FilterIgnoreUser { + sql.WriteString(` INNER JOIN team_member ON team.id = team_member.team_id AND team_member.user_id = ?`) params = append(params, query.UserIdFilter) - } else { - sql.WriteString(getTeamSelectSQLBase(filteredUsers)) - for _, user := range filteredUsers { - params = append(params, user) - } } sql.WriteString(` WHERE team.org_id = ?`) @@ -237,6 +223,8 @@ func (ss *SQLStore) SearchTeams(ctx context.Context, query *models.SearchTeamsQu team := models.Team{} countSess := x.Table("team") + countSess.Where("team.org_id=?", query.OrgId) + if query.Query != "" { countSess.Where(`name `+dialect.LikeStr()+` ?`, queryWithWildcards) } @@ -245,6 +233,18 @@ func (ss *SQLStore) SearchTeams(ctx context.Context, query *models.SearchTeamsQu countSess.Where("name=?", query.Name) } + // If we're not retrieving all results, then only search for teams that this user has access to + if query.UserIdFilter != models.FilterIgnoreUser { + countSess. + Where(` + team.id IN ( + SELECT + team_id + FROM team_member + WHERE team_member.user_id = ? + )`, query.UserIdFilter) + } + count, err := countSess.Count(&team) query.Result.TotalCount = count @@ -261,6 +261,11 @@ func (ss *SQLStore) GetTeamById(ctx context.Context, query *models.GetTeamByIdQu params = append(params, user) } + if query.UserIdFilter != models.FilterIgnoreUser { + sql.WriteString(` INNER JOIN team_member ON team.id = team_member.team_id AND team_member.user_id = ?`) + params = append(params, query.UserIdFilter) + } + sql.WriteString(` WHERE team.org_id = ? and team.id = ?`) params = append(params, query.OrgId, query.Id) diff --git a/pkg/util/proxyutil/proxyutil.go b/pkg/util/proxyutil/proxyutil.go index d6e35721333..3db22a1426e 100644 --- a/pkg/util/proxyutil/proxyutil.go +++ b/pkg/util/proxyutil/proxyutil.go @@ -42,3 +42,9 @@ func ClearCookieHeader(req *http.Request, keepCookiesNames []string) { req.AddCookie(c) } } + +// SetProxyResponseHeaders sets proxy response headers. +// Sets Content-Security-Policy: sandbox +func SetProxyResponseHeaders(header http.Header) { + header.Set("Content-Security-Policy", "sandbox") +} diff --git a/pkg/web/binding.go b/pkg/web/binding.go index 94862b30621..27ab62a47a7 100644 --- a/pkg/web/binding.go +++ b/pkg/web/binding.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "reflect" ) @@ -12,8 +13,15 @@ import ( // Bind deserializes JSON payload from the request func Bind(req *http.Request, v interface{}) error { if req.Body != nil { + m, _, err := mime.ParseMediaType(req.Header.Get("Content-type")) + if err != nil { + return err + } + if m != "application/json" { + return errors.New("bad content type") + } defer func() { _ = req.Body.Close() }() - err := json.NewDecoder(req.Body).Decode(v) + err = json.NewDecoder(req.Body).Decode(v) if err != nil && !errors.Is(err, io.EOF) { return err }