mirror of
https://github.com/grafana/grafana.git
synced 2024-11-21 16:38:03 -06:00
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:
parent
4ed901e1f9
commit
50b649a869
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 (
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
50
pkg/middleware/csp.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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: "]]"},
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
212
pkg/tests/testinfra/testinfra.go
Normal file
212
pkg/tests/testinfra/testinfra.go
Normal 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(®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
|
||||
}
|
64
pkg/tests/web/index_view_test.go
Normal file
64
pkg/tests/web/index_view_test.go
Normal 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()
|
||||
}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user