Middleware: Add CSP support (#29740)

* Middleware: Add support for CSP

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

Co-authored by @iOrcohen
This commit is contained in:
Arve Knudsen 2021-01-12 07:42:32 +01:00 committed by GitHub
parent 4ed901e1f9
commit 50b649a869
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 449 additions and 222 deletions

View File

@ -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

View File

@ -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

View File

@ -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 (

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}

View File

@ -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])

50
pkg/middleware/csp.go Normal file
View File

@ -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)
}
}

View File

@ -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"
}
}

View File

@ -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 {

View File

@ -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: "]]"},

View File

@ -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.

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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(&registry.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)

View File

@ -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(&registry.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
}

View File

@ -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, `<script nonce="[^"]+"`, html)
})
t.Run("CSP disabled", func(t *testing.T) {
grafDir, cfgPath := testinfra.CreateGrafDir(t)
sqlStore := testinfra.SetUpDatabase(t, grafDir)
addr := testinfra.StartGrafana(t, grafDir, cfgPath, sqlStore)
// nolint:bodyclose
resp, html := makeRequest(t, addr)
assert.Empty(t, resp.Header.Get("Content-Security-Policy"))
assert.Regexp(t, `<script nonce=""`, html)
})
}
func makeRequest(t *testing.T, addr string) (*http.Response, string) {
t.Helper()
u := fmt.Sprintf("http://%s", addr)
t.Logf("Making GET request to %s", u)
// nolint:gosec
resp, err := http.Get(u)
require.NoError(t, err)
require.NotNil(t, resp)
t.Cleanup(func() {
err := resp.Body.Close()
assert.NoError(t, err)
})
var b strings.Builder
_, err = io.Copy(&b, resp.Body)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
return resp, b.String()
}

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>
<script nonce="[[.Nonce]]">
// https://github.com/GoogleChromeLabs/tti-polyfill
!(function() {
if ('PerformanceLongTaskTiming' in window) {
@ -36,7 +36,7 @@
<link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].<%= webpack.hash %>.css" />
<script>
<script nonce="[[.Nonce]]">
performance.mark('css done blocking');
</script>
<meta name="apple-mobile-web-app-capable" content="yes" />
@ -221,7 +221,7 @@
</div>
</grafana-app>
<script>
<script nonce="[[.Nonce]]">
window.grafanaBootData = {
user: [[.User]],
settings: [[.Settings]],
@ -239,7 +239,7 @@
[[if .GoogleTagManagerId]]
<!-- Google Tag Manager -->
<script>
<script nonce="[[.Nonce]]">
dataLayer = [
{
IsSignedIn: '[[.User.IsSignedIn]]',
@ -259,7 +259,7 @@
style="display:none;visibility:hidden"
></iframe>
</noscript>
<script>
<script nonce="[[.Nonce]]">
(function(w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
@ -277,16 +277,16 @@
<%
for (key in htmlWebpackPlugin.files.chunks) { %><%
if (htmlWebpackPlugin.files.jsIntegrity) { %>
<script
<script nonce="[[.Nonce]]"
src="<%= htmlWebpackPlugin.files.chunks[key].entry %>"
type="text/javascript"
integrity="<%= htmlWebpackPlugin.files.jsIntegrity[htmlWebpackPlugin.files.js.indexOf(htmlWebpackPlugin.files.chunks[key].entry)] %>"
crossorigin="<%= webpackConfig.output.crossOriginLoading %>"></script><%
} else { %>
<script src="<%= htmlWebpackPlugin.files.chunks[key].entry %>" type="text/javascript"></script><%
<script nonce="[[.Nonce]]" src="<%= htmlWebpackPlugin.files.chunks[key].entry %>" type="text/javascript"></script><%
} %><%
} %>
<script>
<script nonce="[[.Nonce]]">
performance.mark('js done blocking');
</script>
</body>