diff --git a/conf/defaults.ini b/conf/defaults.ini index d15b193e91c..212377c4213 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -227,6 +227,13 @@ x_content_type_options = true # when they detect reflected cross-site scripting (XSS) attacks. x_xss_protection = true +# Enable adding the Content-Security-Policy header to your requests. +# CSP allows to control resources the user agent is allowed to load and helps prevent XSS attacks. +content_security_policy = false + +# Set Content Security Policy template used when adding the Content-Security-Policy header to your requests. +# $NONCE in the template includes a random nonce. +content_security_policy_template = """script-src 'unsafe-eval' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline';img-src 'self' data:;base-uri 'self';connect-src 'self' grafana.com;manifest-src 'self';media-src 'none';form-action 'self';""" #################################### Snapshots ########################### [snapshots] @@ -302,7 +309,7 @@ editors_can_admin = false user_invite_max_lifetime_duration = 24h # Enter a comma-separated list of usernames to hide them in the Grafana UI. These users are shown to Grafana admins and to themselves. -hidden_users = +hidden_users = [auth] # Login cookie name diff --git a/conf/sample.ini b/conf/sample.ini index b36e852b74e..069e69bf979 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -233,6 +233,14 @@ # when they detect reflected cross-site scripting (XSS) attacks. ;x_xss_protection = true +# Enable adding the Content-Security-Policy header to your requests. +# CSP allows to control resources the user agent is allowed to load and helps prevent XSS attacks. +;content_security_policy = false + +# Set Content Security Policy template used when adding the Content-Security-Policy header to your requests. +# $NONCE in the template includes a random nonce. +;content_security_policy_template = """script-src 'unsafe-eval' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline';img-src 'self' data:;base-uri 'self';connect-src 'self' grafana.com;manifest-src 'self';media-src 'none';form-action 'self';""" + #################################### Snapshots ########################### [snapshots] # snapshot sharing options @@ -301,7 +309,7 @@ ;user_invite_max_lifetime_duration = 24h # Enter a comma-separated list of users login to hide them in the Grafana UI. These users are shown to Grafana admins and themselves. -; hidden_users = +; hidden_users = [auth] # Login cookie name diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index b404f32e31c..ff96f181318 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -25,6 +25,8 @@ type IndexViewData struct { AppleTouchIcon template.URL AppTitle string Sentry *setting.Sentry + // Nonce is a cryptographic identifier for use with Content Security Policy. + Nonce string } const ( diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 89a5f5ff732..df90b494599 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -346,7 +346,8 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { m.Use(middleware.ValidateHostHeader(hs.Cfg)) } - m.Use(middleware.HandleNoCacheHeader()) + m.Use(middleware.HandleNoCacheHeader) + m.Use(middleware.AddCSPHeader(hs.Cfg, hs.log)) for _, mw := range hs.middlewares { m.Use(mw) diff --git a/pkg/api/index.go b/pkg/api/index.go index 4a427c45c97..7c72e389eb4 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -427,6 +427,7 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat AppTitle: "Grafana", NavTree: navTree, Sentry: &hs.Cfg.Sentry, + Nonce: c.RequestNonce, } if setting.DisableGravatar { diff --git a/pkg/api/login.go b/pkg/api/login.go index 415b1909dda..ed16aa34ecb 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -21,14 +21,14 @@ import ( ) const ( - ViewIndex = "index" - LoginErrorCookieName = "login_error" + viewIndex = "index" + loginErrorCookieName = "login_error" ) var setIndexViewData = (*HTTPServer).setIndexViewData var getViewIndex = func() string { - return ViewIndex + return viewIndex } func (hs *HTTPServer) ValidateRedirectTo(redirectTo string) error { @@ -96,12 +96,12 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { viewData.Settings["oauth"] = enabledOAuths viewData.Settings["samlEnabled"] = hs.License.HasValidLicense() && hs.Cfg.SAMLEnabled - if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok { + if loginError, ok := tryGetEncryptedCookie(c, loginErrorCookieName); ok { // this cookie is only set whenever an OAuth login fails // therefore the loginError should be passed to the view data // and the view should return immediately before attempting // to login again via OAuth and enter to a redirect loop - cookies.DeleteCookie(c.Resp, LoginErrorCookieName, hs.CookieOptionsFromCfg) + cookies.DeleteCookie(c.Resp, loginErrorCookieName, hs.CookieOptionsFromCfg) viewData.Settings["loginError"] = loginError c.HTML(200, getViewIndex(), viewData) return @@ -317,7 +317,7 @@ func (hs *HTTPServer) trySetEncryptedCookie(ctx *models.ReqContext, cookieName s func (hs *HTTPServer) redirectWithError(ctx *models.ReqContext, err error, v ...interface{}) { ctx.Logger.Error(err.Error(), v...) - if err := hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60); err != nil { + if err := hs.trySetEncryptedCookie(ctx, loginErrorCookieName, err.Error(), 60); err != nil { hs.log.Error("Failed to set encrypted cookie", "err", err) } @@ -326,7 +326,7 @@ func (hs *HTTPServer) redirectWithError(ctx *models.ReqContext, err error, v ... func (hs *HTTPServer) RedirectResponseWithError(ctx *models.ReqContext, err error, v ...interface{}) *RedirectResponse { ctx.Logger.Error(err.Error(), v...) - if err := hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60); err != nil { + if err := hs.trySetEncryptedCookie(ctx, loginErrorCookieName, err.Error(), 60); err != nil { hs.log.Error("Failed to set encrypted cookie", "err", err) } diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 8c67fc2eec8..d3d40ba6d12 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -103,25 +103,34 @@ func TestLoginErrorCookieAPIEndpoint(t *testing.T) { cfg.LoginCookieName = "grafana_session" setting.SecretKey = "login_testing" - setting.OAuthService = &setting.OAuther{} - setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) - setting.OAuthService.OAuthInfos["github"] = &setting.OAuthInfo{ - ClientId: "fake", - ClientSecret: "fakefake", - Enabled: true, - AllowSignup: true, - Name: "github", + origOAuthService := setting.OAuthService + origOAuthAutoLogin := setting.OAuthAutoLogin + t.Cleanup(func() { + setting.OAuthService = origOAuthService + setting.OAuthAutoLogin = origOAuthAutoLogin + }) + setting.OAuthService = &setting.OAuther{ + OAuthInfos: map[string]*setting.OAuthInfo{ + "github": { + ClientId: "fake", + ClientSecret: "fakefake", + Enabled: true, + AllowSignup: true, + Name: "github", + }, + }, } setting.OAuthAutoLogin = true oauthError := errors.New("User not a member of one of the required organizations") - encryptedError, _ := util.Encrypt([]byte(oauthError.Error()), setting.SecretKey) + encryptedError, err := util.Encrypt([]byte(oauthError.Error()), setting.SecretKey) + require.NoError(t, err) expCookiePath := "/" if len(setting.AppSubUrl) > 0 { expCookiePath = setting.AppSubUrl } cookie := http.Cookie{ - Name: LoginErrorCookieName, + Name: loginErrorCookieName, MaxAge: 60, Value: hex.EncodeToString(encryptedError), HttpOnly: true, @@ -131,10 +140,10 @@ func TestLoginErrorCookieAPIEndpoint(t *testing.T) { } sc.m.Get(sc.url, sc.defaultHandler) sc.fakeReqNoAssertionsWithCookie("GET", sc.url, cookie).exec() - assert.Equal(t, sc.resp.Code, 200) + require.Equal(t, 200, sc.resp.Code) responseString, err := getBody(sc.resp) - assert.NoError(t, err) + require.NoError(t, err) assert.True(t, strings.Contains(responseString, oauthError.Error())) } @@ -276,7 +285,7 @@ func TestLoginViewRedirect(t *testing.T) { } sc.m.Get(sc.url, sc.defaultHandler) sc.fakeReqNoAssertionsWithCookie("GET", sc.url, cookie).exec() - assert.Equal(t, c.status, sc.resp.Code) + require.Equal(t, c.status, sc.resp.Code) if c.status == 302 { location, ok := sc.resp.Header()["Location"] assert.True(t, ok) @@ -304,7 +313,7 @@ func TestLoginViewRedirect(t *testing.T) { } responseString, err := getBody(sc.resp) - assert.NoError(t, err) + require.NoError(t, err) if c.err != nil { assert.True(t, strings.Contains(responseString, c.err.Error())) } @@ -443,10 +452,10 @@ func TestLoginPostRedirect(t *testing.T) { } sc.m.Post(sc.url, sc.defaultHandler) sc.fakeReqNoAssertionsWithCookie("POST", sc.url, cookie).exec() - assert.Equal(t, sc.resp.Code, 200) + require.Equal(t, 200, sc.resp.Code) respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) - assert.NoError(t, err) + require.NoError(t, err) redirectURL := respJSON.Get("redirectUrl").MustString() if c.err != nil { assert.Equal(t, "", redirectURL) @@ -496,10 +505,10 @@ func TestLoginOAuthRedirect(t *testing.T) { sc.m.Get(sc.url, sc.defaultHandler) sc.fakeReqNoAssertions("GET", sc.url).exec() - assert.Equal(t, sc.resp.Code, 307) + require.Equal(t, 307, sc.resp.Code) location, ok := sc.resp.Header()["Location"] assert.True(t, ok) - assert.Equal(t, location[0], "/login/github") + assert.Equal(t, "/login/github", location[0]) } func TestLoginInternal(t *testing.T) { @@ -532,16 +541,16 @@ func TestLoginInternal(t *testing.T) { sc.fakeReqNoAssertions("GET", sc.url).exec() // Shouldn't redirect to the OAuth login URL - assert.Equal(t, sc.resp.Code, 200) + assert.Equal(t, 200, sc.resp.Code) } func TestAuthProxyLoginEnableLoginTokenDisabled(t *testing.T) { sc := setupAuthProxyLoginTest(t, false) - assert.Equal(t, sc.resp.Code, 302) + require.Equal(t, 302, sc.resp.Code) location, ok := sc.resp.Header()["Location"] assert.True(t, ok) - assert.Equal(t, location[0], "/") + assert.Equal(t, "/", location[0]) _, ok = sc.resp.Header()["Set-Cookie"] assert.False(t, ok, "Set-Cookie does not exist") @@ -549,11 +558,11 @@ func TestAuthProxyLoginEnableLoginTokenDisabled(t *testing.T) { func TestAuthProxyLoginWithEnableLoginToken(t *testing.T) { sc := setupAuthProxyLoginTest(t, true) - require.Equal(t, sc.resp.Code, 302) + require.Equal(t, 302, sc.resp.Code) location, ok := sc.resp.Header()["Location"] assert.True(t, ok) - assert.Equal(t, location[0], "/") + assert.Equal(t, "/", location[0]) setCookie := sc.resp.Header()["Set-Cookie"] require.NotNil(t, setCookie, "Set-Cookie should exist") assert.Equal(t, "grafana_session=; Path=/; Max-Age=0; HttpOnly", setCookie[0]) diff --git a/pkg/middleware/csp.go b/pkg/middleware/csp.go new file mode 100644 index 00000000000..6013205e9e5 --- /dev/null +++ b/pkg/middleware/csp.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "net/http" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + macaron "gopkg.in/macaron.v1" +) + +// AddCSPHeader adds the Content Security Policy header. +func AddCSPHeader(cfg *setting.Cfg, logger log.Logger) macaron.Handler { + return func(w http.ResponseWriter, req *http.Request, c *macaron.Context) { + if !cfg.CSPEnabled { + logger.Debug("Not adding CSP header to response since it's disabled") + return + } + + logger.Debug("Adding CSP header to response", "cfg", fmt.Sprintf("%p", cfg)) + + ctx, ok := c.Data["ctx"].(*models.ReqContext) + if !ok { + panic("Failed to convert context into models.ReqContext") + } + + if cfg.CSPTemplate == "" { + logger.Debug("CSP template not configured, so returning 500") + ctx.JsonApiErr(500, "CSP template has to be configured", nil) + return + } + + var buf [16]byte + if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil { + logger.Error("Failed to generate CSP nonce", "err", err) + ctx.JsonApiErr(500, "Failed to generate CSP nonce", err) + } + + nonce := base64.RawStdEncoding.EncodeToString(buf[:]) + val := strings.ReplaceAll(cfg.CSPTemplate, "$NONCE", fmt.Sprintf("'nonce-%s'", nonce)) + w.Header().Set("Content-Security-Policy", val) + ctx.RequestNonce = nonce + logger.Debug("Successfully generated CSP nonce", "nonce", nonce) + } +} diff --git a/pkg/middleware/headers.go b/pkg/middleware/headers.go deleted file mode 100644 index 96c670468e9..00000000000 --- a/pkg/middleware/headers.go +++ /dev/null @@ -1,14 +0,0 @@ -package middleware - -import ( - "github.com/grafana/grafana/pkg/models" - macaron "gopkg.in/macaron.v1" -) - -const HeaderNameNoBackendCache = "X-Grafana-NoCache" - -func HandleNoCacheHeader() macaron.Handler { - return func(ctx *models.ReqContext) { - ctx.SkipCache = ctx.Req.Header.Get(HeaderNameNoBackendCache) == "true" - } -} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 02048480ff4..9dc47fa8325 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -20,16 +20,20 @@ var ( ReqOrgAdmin = RoleAuth(models.ROLE_ADMIN) ) +func HandleNoCacheHeader(ctx *models.ReqContext) { + ctx.SkipCache = ctx.Req.Header.Get("X-Grafana-NoCache") == "true" +} + func AddDefaultResponseHeaders(cfg *setting.Cfg) macaron.Handler { - return func(ctx *macaron.Context) { - ctx.Resp.Before(func(w macaron.ResponseWriter) { + return func(c *macaron.Context) { + c.Resp.Before(func(w macaron.ResponseWriter) { // if response has already been written, skip. if w.Written() { return } - if !strings.HasPrefix(ctx.Req.URL.Path, "/api/datasources/proxy/") { - addNoCacheHeaders(ctx.Resp) + if !strings.HasPrefix(c.Req.URL.Path, "/api/datasources/proxy/") { + addNoCacheHeaders(c.Resp) } if !cfg.AllowEmbedding { diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 56df3119110..43518551483 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/gtime" "github.com/grafana/grafana/pkg/infra/fs" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/models" @@ -539,6 +540,8 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( t.Run(desc, func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) + logger := log.New("test") + loginMaxLifetime, err := gtime.ParseDuration("30d") require.NoError(t, err) cfg := setting.NewCfg() @@ -560,6 +563,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( sc.m = macaron.New() sc.m.Use(AddDefaultResponseHeaders(cfg)) + sc.m.Use(AddCSPHeader(cfg, logger)) sc.m.Use(macaron.Renderer(macaron.RenderOptions{ Directory: viewsPath, Delims: macaron.Delims{Left: "[[", Right: "]]"}, diff --git a/pkg/models/context.go b/pkg/models/context.go index 9feea510365..850eb6237ed 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -19,6 +19,8 @@ type ReqContext struct { AllowAnonymous bool SkipCache bool Logger log.Logger + // RequestNonce is a cryptographic request identifier for use with Content Security Policy. + RequestNonce string } // Handle handles and logs error by given status. diff --git a/pkg/server/server.go b/pkg/server/server.go index e023f64dbca..099c102bc2b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -65,7 +65,9 @@ func New(cfg Config) (*Server, error) { shutdownFn: shutdownFn, childRoutines: childRoutines, log: log.New("server"), - cfg: setting.NewCfg(), + // Need to use the singleton setting.Cfg instance, to make sure we use the same as is injected in the DI + // graph + cfg: setting.GetCfg(), configFile: cfg.ConfigFile, homePath: cfg.HomePath, diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index 2b06bd42765..43f7180e612 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -57,7 +57,7 @@ func (rs *RenderingService) Init() error { // ensure ImagesDir exists err := os.MkdirAll(rs.Cfg.ImagesDir, 0700) if err != nil { - return err + return fmt.Errorf("failed to create images directory %q: %w", rs.Cfg.ImagesDir, err) } // set value used for domain attribute of renderKey cookie diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index a4ed4639cf7..f044faa8f7e 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -236,6 +236,10 @@ type Cfg struct { StrictTransportSecurityMaxAge int StrictTransportSecurityPreload bool StrictTransportSecuritySubDomains bool + // CSPEnabled toggles Content Security Policy support. + CSPEnabled bool + // CSPTemplate contains the Content Security Policy template. + CSPTemplate string TempDataLifetime time.Duration PluginsEnableAlpha bool @@ -596,8 +600,6 @@ func loadSpecifiedConfigFile(configFile string, masterFile *ini.File) error { } func (cfg *Cfg) loadConfiguration(args *CommandLineArgs) (*ini.File, error) { - var err error - // load config defaults defaultConfigFile := path.Join(HomePath, "conf/defaults.ini") configFiles = append(configFiles, defaultConfigFile) @@ -677,7 +679,11 @@ func setHomePath(args *CommandLineArgs) { return } - HomePath, _ = filepath.Abs(".") + var err error + HomePath, err = filepath.Abs(".") + if err != nil { + panic(err) + } // check if homepath is correct if pathExists(filepath.Join(HomePath, "conf/defaults.ini")) { return @@ -698,6 +704,21 @@ func NewCfg() *Cfg { } } +var theCfg *Cfg + +// GetCfg gets the Cfg singleton. +// XXX: This is only required for integration tests so that the configuration can be reset for each test, +// as due to how the current DI framework functions, we can't create a new Cfg object every time (the services +// constituting the DI graph, and referring to a Cfg instance, get created only once). +func GetCfg() *Cfg { + if theCfg != nil { + return theCfg + } + + theCfg = NewCfg() + return theCfg +} + func (cfg *Cfg) validateStaticRootPath() error { if skipStaticRootValidation { return nil @@ -1010,6 +1031,8 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error { cfg.StrictTransportSecurityMaxAge = security.Key("strict_transport_security_max_age_seconds").MustInt(86400) cfg.StrictTransportSecurityPreload = security.Key("strict_transport_security_preload").MustBool(false) cfg.StrictTransportSecuritySubDomains = security.Key("strict_transport_security_subdomains").MustBool(false) + cfg.CSPEnabled = security.Key("content_security_policy").MustBool(false) + cfg.CSPTemplate = security.Key("content_security_policy_template").MustString("") // read data source proxy whitelist DataProxyWhiteList = make(map[string]bool) diff --git a/pkg/tests/api/metrics/api_metrics_test.go b/pkg/tests/api/metrics/api_metrics_test.go index 44ae7afbe5e..6ad40c985ef 100644 --- a/pkg/tests/api/metrics/api_metrics_test.go +++ b/pkg/tests/api/metrics/api_metrics_test.go @@ -6,11 +6,7 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" - "net" "net/http" - "os" - "path/filepath" "testing" "time" @@ -19,26 +15,23 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" "github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/registry" - "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tsdb" "github.com/grafana/grafana/pkg/tsdb/cloudwatch" cwapi "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/fs" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/ini.v1" ) func TestQueryCloudWatchMetrics(t *testing.T) { - grafDir, cfgPath := createGrafDir(t) + grafDir, cfgPath := testinfra.CreateGrafDir(t) sqlStore := setUpDatabase(t, grafDir) - addr := startGrafana(t, grafDir, cfgPath, sqlStore) + addr := testinfra.StartGrafana(t, grafDir, cfgPath, sqlStore) origNewCWClient := cloudwatch.NewCWClient t.Cleanup(func() { @@ -108,9 +101,9 @@ func TestQueryCloudWatchMetrics(t *testing.T) { } func TestQueryCloudWatchLogs(t *testing.T) { - grafDir, cfgPath := createGrafDir(t) + grafDir, cfgPath := testinfra.CreateGrafDir(t) sqlStore := setUpDatabase(t, grafDir) - addr := startGrafana(t, grafDir, cfgPath, sqlStore) + addr := testinfra.StartGrafana(t, grafDir, cfgPath, sqlStore) origNewCWLogsClient := cloudwatch.NewCWLogsClient t.Cleanup(func() { @@ -193,153 +186,11 @@ func makeCWRequest(t *testing.T, req dtos.MetricRequest, addr string) tsdb.Respo return tr } -func createGrafDir(t *testing.T) (string, string) { - t.Helper() - - tmpDir, err := ioutil.TempDir("", "") - require.NoError(t, err) - t.Cleanup(func() { - err := os.RemoveAll(tmpDir) - assert.NoError(t, err) - }) - - rootDir := filepath.Join("..", "..", "..", "..") - - cfgDir := filepath.Join(tmpDir, "conf") - err = os.MkdirAll(cfgDir, 0750) - require.NoError(t, err) - dataDir := filepath.Join(tmpDir, "data") - // nolint:gosec - err = os.MkdirAll(dataDir, 0750) - require.NoError(t, err) - logsDir := filepath.Join(tmpDir, "logs") - pluginsDir := filepath.Join(tmpDir, "plugins") - publicDir := filepath.Join(tmpDir, "public") - err = os.MkdirAll(publicDir, 0750) - require.NoError(t, err) - emailsDir := filepath.Join(publicDir, "emails") - err = fs.CopyRecursive(filepath.Join(rootDir, "public", "emails"), emailsDir) - require.NoError(t, err) - provDir := filepath.Join(cfgDir, "provisioning") - provDSDir := filepath.Join(provDir, "datasources") - err = os.MkdirAll(provDSDir, 0750) - require.NoError(t, err) - provNotifiersDir := filepath.Join(provDir, "notifiers") - err = os.MkdirAll(provNotifiersDir, 0750) - require.NoError(t, err) - provPluginsDir := filepath.Join(provDir, "plugins") - err = os.MkdirAll(provPluginsDir, 0750) - require.NoError(t, err) - provDashboardsDir := filepath.Join(provDir, "dashboards") - err = os.MkdirAll(provDashboardsDir, 0750) - require.NoError(t, err) - - cfg := ini.Empty() - dfltSect := cfg.Section("") - _, err = dfltSect.NewKey("app_mode", "development") - require.NoError(t, err) - - pathsSect, err := cfg.NewSection("paths") - require.NoError(t, err) - _, err = pathsSect.NewKey("data", dataDir) - require.NoError(t, err) - _, err = pathsSect.NewKey("logs", logsDir) - require.NoError(t, err) - _, err = pathsSect.NewKey("plugins", pluginsDir) - require.NoError(t, err) - - logSect, err := cfg.NewSection("log") - require.NoError(t, err) - _, err = logSect.NewKey("level", "debug") - require.NoError(t, err) - - serverSect, err := cfg.NewSection("server") - require.NoError(t, err) - _, err = serverSect.NewKey("port", "0") - require.NoError(t, err) - - anonSect, err := cfg.NewSection("auth.anonymous") - require.NoError(t, err) - _, err = anonSect.NewKey("enabled", "true") - require.NoError(t, err) - - cfgPath := filepath.Join(cfgDir, "test.ini") - err = cfg.SaveTo(cfgPath) - require.NoError(t, err) - - err = fs.CopyFile(filepath.Join(rootDir, "conf", "defaults.ini"), filepath.Join(cfgDir, "defaults.ini")) - require.NoError(t, err) - - return tmpDir, cfgPath -} - -func startGrafana(t *testing.T, grafDir, cfgPath string, sqlStore *sqlstore.SQLStore) string { - t.Helper() - - origSQLStore := registry.GetService(sqlstore.ServiceName) - t.Cleanup(func() { - registry.Register(origSQLStore) - }) - registry.Register(®istry.Descriptor{ - Name: sqlstore.ServiceName, - Instance: sqlStore, - InitPriority: sqlstore.InitPriority, - }) - - t.Logf("Registered SQL store %p", sqlStore) - - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - server, err := server.New(server.Config{ - ConfigFile: cfgPath, - HomePath: grafDir, - Listener: listener, - }) - require.NoError(t, err) - - t.Cleanup(func() { - // Have to reset the route register between tests, since it doesn't get re-created - server.HTTPServer.RouteRegister.Reset() - }) - - go func() { - // When the server runs, it will also build and initialize the service graph - if err := server.Run(); err != nil { - t.Log("Server exited uncleanly", "error", err) - } - }() - t.Cleanup(func() { - server.Shutdown("") - }) - - // Wait for Grafana to be ready - addr := listener.Addr().String() - resp, err := http.Get(fmt.Sprintf("http://%s/api/health", addr)) - require.NoError(t, err) - require.NotNil(t, resp) - t.Cleanup(func() { - err := resp.Body.Close() - assert.NoError(t, err) - }) - require.Equal(t, 200, resp.StatusCode) - - t.Logf("Grafana is listening on %s", addr) - - return addr -} - func setUpDatabase(t *testing.T, grafDir string) *sqlstore.SQLStore { t.Helper() - sqlStore := sqlstore.InitTestDB(t, sqlstore.InitTestDBOpt{ - EnsureDefaultOrgAndUser: true, - }) - // We need the main org, since it's used for anonymous access - org, err := sqlStore.GetOrgByName(sqlstore.MainOrgName) - require.NoError(t, err) - require.NotNil(t, org) - - err = sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + sqlStore := testinfra.SetUpDatabase(t, grafDir) + err := sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { _, err := sess.Insert(&models.DataSource{ Id: 1, // This will be the ID of the main org @@ -352,6 +203,7 @@ func setUpDatabase(t *testing.T, grafDir string) *sqlstore.SQLStore { return err }) require.NoError(t, err) + // Make sure changes are synced with other goroutines err = sqlStore.Sync() require.NoError(t, err) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go new file mode 100644 index 00000000000..1067276bbf0 --- /dev/null +++ b/pkg/tests/testinfra/testinfra.go @@ -0,0 +1,212 @@ +package testinfra + +import ( + "fmt" + "io/ioutil" + "net" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/grafana/grafana/pkg/infra/fs" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" +) + +// StartGrafana starts a Grafana server. +// The server address is returned. +func StartGrafana(t *testing.T, grafDir, cfgPath string, sqlStore *sqlstore.SQLStore) string { + t.Helper() + + origSQLStore := registry.GetService(sqlstore.ServiceName) + t.Cleanup(func() { + registry.Register(origSQLStore) + }) + registry.Register(®istry.Descriptor{ + Name: sqlstore.ServiceName, + Instance: sqlStore, + InitPriority: sqlstore.InitPriority, + }) + + t.Logf("Registered SQL store %p", sqlStore) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + server, err := server.New(server.Config{ + ConfigFile: cfgPath, + HomePath: grafDir, + Listener: listener, + }) + require.NoError(t, err) + + t.Cleanup(func() { + // Have to reset the route register between tests, since it doesn't get re-created + server.HTTPServer.RouteRegister.Reset() + }) + + go func() { + // When the server runs, it will also build and initialize the service graph + if err := server.Run(); err != nil { + t.Log("Server exited uncleanly", "error", err) + } + }() + t.Cleanup(func() { + server.Shutdown("") + }) + + // Wait for Grafana to be ready + addr := listener.Addr().String() + resp, err := http.Get(fmt.Sprintf("http://%s/api/health", addr)) + require.NoError(t, err) + require.NotNil(t, resp) + t.Cleanup(func() { + err := resp.Body.Close() + assert.NoError(t, err) + }) + require.Equal(t, 200, resp.StatusCode) + + t.Logf("Grafana is listening on %s", addr) + + return addr +} + +// SetUpDatabase sets up the Grafana database. +func SetUpDatabase(t *testing.T, grafDir string) *sqlstore.SQLStore { + t.Helper() + + sqlStore := sqlstore.InitTestDB(t, sqlstore.InitTestDBOpt{ + EnsureDefaultOrgAndUser: true, + }) + // We need the main org, since it's used for anonymous access + org, err := sqlStore.GetOrgByName(sqlstore.MainOrgName) + require.NoError(t, err) + require.NotNil(t, org) + + // Make sure changes are synced with other goroutines + err = sqlStore.Sync() + require.NoError(t, err) + + return sqlStore +} + +// CreateGrafDir creates the Grafana directory. +func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { + t.Helper() + + tmpDir, err := ioutil.TempDir("", "") + require.NoError(t, err) + t.Cleanup(func() { + err := os.RemoveAll(tmpDir) + assert.NoError(t, err) + }) + + // Search upwards in directory tree for project root + var rootDir string + found := false + for i := 0; i < 20; i++ { + rootDir = filepath.Join(rootDir, "..") + exists, err := fs.Exists(filepath.Join(rootDir, "public", "views")) + require.NoError(t, err) + if exists { + found = true + break + } + } + require.True(t, found, "Couldn't detect project root directory") + + cfgDir := filepath.Join(tmpDir, "conf") + err = os.MkdirAll(cfgDir, 0750) + require.NoError(t, err) + dataDir := filepath.Join(tmpDir, "data") + // nolint:gosec + err = os.MkdirAll(dataDir, 0750) + require.NoError(t, err) + logsDir := filepath.Join(tmpDir, "logs") + pluginsDir := filepath.Join(tmpDir, "plugins") + publicDir := filepath.Join(tmpDir, "public") + err = os.MkdirAll(publicDir, 0750) + require.NoError(t, err) + viewsDir := filepath.Join(publicDir, "views") + err = fs.CopyRecursive(filepath.Join(rootDir, "public", "views"), viewsDir) + require.NoError(t, err) + // Copy index template to index.html, since Grafana will try to use the latter + err = fs.CopyFile(filepath.Join(rootDir, "public", "views", "index-template.html"), + filepath.Join(viewsDir, "index.html")) + require.NoError(t, err) + // Copy error template to error.html, since Grafana will try to use the latter + err = fs.CopyFile(filepath.Join(rootDir, "public", "views", "error-template.html"), + filepath.Join(viewsDir, "error.html")) + require.NoError(t, err) + emailsDir := filepath.Join(publicDir, "emails") + err = fs.CopyRecursive(filepath.Join(rootDir, "public", "emails"), emailsDir) + require.NoError(t, err) + provDir := filepath.Join(cfgDir, "provisioning") + provDSDir := filepath.Join(provDir, "datasources") + err = os.MkdirAll(provDSDir, 0750) + require.NoError(t, err) + provNotifiersDir := filepath.Join(provDir, "notifiers") + err = os.MkdirAll(provNotifiersDir, 0750) + require.NoError(t, err) + provPluginsDir := filepath.Join(provDir, "plugins") + err = os.MkdirAll(provPluginsDir, 0750) + require.NoError(t, err) + provDashboardsDir := filepath.Join(provDir, "dashboards") + err = os.MkdirAll(provDashboardsDir, 0750) + require.NoError(t, err) + + cfg := ini.Empty() + dfltSect := cfg.Section("") + _, err = dfltSect.NewKey("app_mode", "development") + require.NoError(t, err) + + pathsSect, err := cfg.NewSection("paths") + require.NoError(t, err) + _, err = pathsSect.NewKey("data", dataDir) + require.NoError(t, err) + _, err = pathsSect.NewKey("logs", logsDir) + require.NoError(t, err) + _, err = pathsSect.NewKey("plugins", pluginsDir) + require.NoError(t, err) + + logSect, err := cfg.NewSection("log") + require.NoError(t, err) + _, err = logSect.NewKey("level", "debug") + require.NoError(t, err) + + serverSect, err := cfg.NewSection("server") + require.NoError(t, err) + _, err = serverSect.NewKey("port", "0") + require.NoError(t, err) + + anonSect, err := cfg.NewSection("auth.anonymous") + require.NoError(t, err) + _, err = anonSect.NewKey("enabled", "true") + require.NoError(t, err) + + for _, o := range opts { + if o.EnableCSP { + securitySect, err := cfg.NewSection("security") + require.NoError(t, err) + _, err = securitySect.NewKey("content_security_policy", "true") + require.NoError(t, err) + } + } + + cfgPath := filepath.Join(cfgDir, "test.ini") + err = cfg.SaveTo(cfgPath) + require.NoError(t, err) + + err = fs.CopyFile(filepath.Join(rootDir, "conf", "defaults.ini"), filepath.Join(cfgDir, "defaults.ini")) + require.NoError(t, err) + + return tmpDir, cfgPath +} + +type GrafanaOpts struct { + EnableCSP bool +} diff --git a/pkg/tests/web/index_view_test.go b/pkg/tests/web/index_view_test.go new file mode 100644 index 00000000000..a97037a9c1e --- /dev/null +++ b/pkg/tests/web/index_view_test.go @@ -0,0 +1,64 @@ +package web + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIndexView tests the Grafana index view. +func TestIndexView(t *testing.T) { + t.Run("CSP enabled", func(t *testing.T) { + grafDir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableCSP: true, + }) + sqlStore := testinfra.SetUpDatabase(t, grafDir) + addr := testinfra.StartGrafana(t, grafDir, cfgPath, sqlStore) + + // nolint:bodyclose + resp, html := makeRequest(t, addr) + + assert.Regexp(t, "script-src 'unsafe-eval' 'strict-dynamic' 'nonce-[^']+';object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline';img-src 'self' data:;base-uri 'self';connect-src 'self' grafana.com;manifest-src 'self';media-src 'none';form-action 'self';", resp.Header.Get("Content-Security-Policy")) + assert.Regexp(t, ` @@ -221,7 +221,7 @@ - <% } else { %> - <% + <% } %><% } %> -