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 { %>
- <%
+ <%
} %><%
} %>
-