diff --git a/conf/defaults.ini b/conf/defaults.ini
index b071aabd65cc..8e00ce19af88 100644
--- a/conf/defaults.ini
+++ b/conf/defaults.ini
@@ -329,6 +329,15 @@ content_security_policy = false
# $ROOT_PATH is server.root_url without the protocol.
content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';"""
+# Enable adding the Content-Security-Policy-Report-Only header to your requests.
+# Allows you to monitor the effects of a policy without enforcing it.
+content_security_policy_report_only = false
+
+# Set Content Security Policy Report Only template used when adding the Content-Security-Policy-Report-Only header to your requests.
+# $NONCE in the template includes a random nonce.
+# $ROOT_PATH is server.root_url without the protocol.
+content_security_policy_report_only_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';"""
+
# Controls if old angular plugins are supported or not. This will be disabled by default in future release
angular_support_enabled = true
diff --git a/conf/sample.ini b/conf/sample.ini
index 30d3e0f65b8e..6272b6285444 100644
--- a/conf/sample.ini
+++ b/conf/sample.ini
@@ -330,6 +330,14 @@
# $ROOT_PATH is server.root_url without the protocol.
;content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';"""
+# Enable adding the Content-Security-Policy-Report-Only header to your requests.
+# Allows you to monitor the effects of a policy without enforcing it.
+;content_security_policy_report_only = false
+
+# Set Content Security Policy Report Only template used when adding the Content-Security-Policy-Report-Only header to your requests.
+# $NONCE in the template includes a random nonce.
+# $ROOT_PATH is server.root_url without the protocol.
+;content_security_policy_report_only_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';"""
# Controls if old angular plugins are supported or not. This will be disabled by default in future release
;angular_support_enabled = true
diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md
index 0cd2970e338d..d85f3324b270 100644
--- a/docs/sources/setup-grafana/configure-grafana/_index.md
+++ b/docs/sources/setup-grafana/configure-grafana/_index.md
@@ -623,7 +623,16 @@ Set to `true` to add the Content-Security-Policy header to your requests. CSP al
### content_security_policy_template
-Set Content Security Policy template used when adding the Content-Security-Policy header to your requests. `$NONCE` in the template includes a random nonce.
+Set the policy template that will be used when adding the `Content-Security-Policy` header to your requests. `$NONCE` in the template includes a random nonce.
+
+### content_security_policy_report_only
+
+Set to `true` to add the `Content-Security-Policy-Report-Only` header to your requests. CSP in Report Only mode enables you to experiment with policies by monitoring their effects without enforcing them.
+You can enable both policies simultaneously.
+
+### content_security_policy_template
+
+Set the policy template that will be used when adding the `Content-Security-Policy-Report-Only` header to your requests. `$NONCE` in the template includes a random nonce.
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index 9392c03a911a..f2cfce24fc16 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -623,7 +623,10 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
}
m.Use(middleware.HandleNoCacheHeader)
- m.UseMiddleware(middleware.AddCSPHeader(hs.Cfg, hs.log))
+
+ if hs.Cfg.CSPEnabled || hs.Cfg.CSPReportOnlyEnabled {
+ m.UseMiddleware(middleware.ContentSecurityPolicy(hs.Cfg, hs.log))
+ }
for _, mw := range hs.middlewares {
m.Use(mw)
diff --git a/pkg/middleware/csp.go b/pkg/middleware/csp.go
index 2ea9614dfe07..c5bd86917689 100644
--- a/pkg/middleware/csp.go
+++ b/pkg/middleware/csp.go
@@ -14,40 +14,64 @@ import (
"github.com/grafana/grafana/pkg/setting"
)
-// AddCSPHeader adds the Content Security Policy header.
-func AddCSPHeader(cfg *setting.Cfg, logger log.Logger) func(http.Handler) http.Handler {
+// ContentSecurityPolicy sets the configured Content-Security-Policy and/or Content-Security-Policy-Report-Only header(s) in the response.
+func ContentSecurityPolicy(cfg *setting.Cfg, logger log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
- if !cfg.CSPEnabled {
- next.ServeHTTP(rw, req)
- return
- }
-
- logger.Debug("Adding CSP header to response", "cfg", fmt.Sprintf("%p", cfg))
-
- ctx := contexthandler.FromContext(req.Context())
- 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))
-
- re := regexp.MustCompile(`^\w+:(//)?`)
- rootPath := re.ReplaceAllString(cfg.AppURL, "")
- val = strings.ReplaceAll(val, "$ROOT_PATH", rootPath)
- rw.Header().Set("Content-Security-Policy", val)
- ctx.RequestNonce = nonce
- logger.Debug("Successfully generated CSP nonce", "nonce", nonce)
- next.ServeHTTP(rw, req)
- })
+ if cfg.CSPEnabled {
+ next = cspMiddleware(cfg, next, logger)
+ }
+ if cfg.CSPReportOnlyEnabled {
+ next = cspReportOnlyMiddleware(cfg, next, logger)
+ }
+ next = nonceMiddleware(next, logger)
+ return next
}
}
+
+func nonceMiddleware(next http.Handler, logger log.Logger) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ ctx := contexthandler.FromContext(req.Context())
+ nonce, err := generateNonce()
+ if err != nil {
+ logger.Error("Failed to generate CSP nonce", "err", err)
+ ctx.JsonApiErr(500, "Failed to generate CSP nonce", err)
+ }
+ ctx.RequestNonce = nonce
+ logger.Debug("Successfully generated CSP nonce", "nonce", nonce)
+ next.ServeHTTP(rw, req)
+ })
+}
+
+func cspMiddleware(cfg *setting.Cfg, next http.Handler, logger log.Logger) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ ctx := contexthandler.FromContext(req.Context())
+ policy := replacePolicyVariables(cfg.CSPTemplate, cfg.AppURL, ctx.RequestNonce)
+ rw.Header().Set("Content-Security-Policy", policy)
+ next.ServeHTTP(rw, req)
+ })
+}
+
+func cspReportOnlyMiddleware(cfg *setting.Cfg, next http.Handler, logger log.Logger) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ ctx := contexthandler.FromContext(req.Context())
+ policy := replacePolicyVariables(cfg.CSPReportOnlyTemplate, cfg.AppURL, ctx.RequestNonce)
+ rw.Header().Set("Content-Security-Policy-Report-Only", policy)
+ next.ServeHTTP(rw, req)
+ })
+}
+
+func replacePolicyVariables(policyTemplate, appURL, nonce string) string {
+ policy := strings.ReplaceAll(policyTemplate, "$NONCE", fmt.Sprintf("'nonce-%s'", nonce))
+ re := regexp.MustCompile(`^\w+:(//)?`)
+ rootPath := re.ReplaceAllString(appURL, "")
+ policy = strings.ReplaceAll(policy, "$ROOT_PATH", rootPath)
+ return policy
+}
+
+func generateNonce() (string, error) {
+ var buf [16]byte
+ if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil {
+ return "", err
+ }
+ return base64.RawStdEncoding.EncodeToString(buf[:]), nil
+}
diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go
index 28839e6198cc..b1af65c32a4f 100644
--- a/pkg/middleware/middleware_test.go
+++ b/pkg/middleware/middleware_test.go
@@ -83,6 +83,47 @@ func TestMiddleWareSecurityHeaders(t *testing.T) {
})
}
+func TestMiddleWareContentSecurityPolicyHeaders(t *testing.T) {
+ policy := `script-src 'self' 'strict-dynamic' 'nonce-[^']+';connect-src 'self' ws://localhost:3000/ wss://localhost:3000/;`
+
+ middlewareScenario(t, "middleware should add Content-Security-Policy", func(t *testing.T, sc *scenarioContext) {
+ sc.fakeReq("GET", "/api/").exec()
+ assert.Regexp(t, policy, sc.resp.Header().Get("Content-Security-Policy"))
+ }, func(cfg *setting.Cfg) {
+ cfg.CSPEnabled = true
+ cfg.CSPTemplate = "script-src 'self' 'strict-dynamic' $NONCE;connect-src 'self' ws://$ROOT_PATH wss://$ROOT_PATH;"
+ cfg.AppURL = "http://localhost:3000/"
+ })
+
+ middlewareScenario(t, "middleware should add Content-Security-Policy-Report-Only", func(t *testing.T, sc *scenarioContext) {
+ sc.fakeReq("GET", "/api/").exec()
+ assert.Regexp(t, policy, sc.resp.Header().Get("Content-Security-Policy-Report-Only"))
+ }, func(cfg *setting.Cfg) {
+ cfg.CSPReportOnlyEnabled = true
+ cfg.CSPReportOnlyTemplate = "script-src 'self' 'strict-dynamic' $NONCE;connect-src 'self' ws://$ROOT_PATH wss://$ROOT_PATH;"
+ cfg.AppURL = "http://localhost:3000/"
+ })
+
+ middlewareScenario(t, "middleware can add both CSP and CSP-Report-Only", func(t *testing.T, sc *scenarioContext) {
+ sc.fakeReq("GET", "/api/").exec()
+
+ cspHeader := sc.resp.Header().Get("Content-Security-Policy")
+ cspReportOnlyHeader := sc.resp.Header().Get("Content-Security-Policy-Report-Only")
+
+ assert.Regexp(t, policy, cspHeader)
+ assert.Regexp(t, policy, cspReportOnlyHeader)
+
+ // assert CSP-Report-Only reuses the same nonce as CSP
+ assert.Equal(t, cspHeader, cspReportOnlyHeader)
+ }, func(cfg *setting.Cfg) {
+ cfg.CSPEnabled = true
+ cfg.CSPTemplate = "script-src 'self' 'strict-dynamic' $NONCE;connect-src 'self' ws://$ROOT_PATH wss://$ROOT_PATH;"
+ cfg.CSPReportOnlyEnabled = true
+ cfg.CSPReportOnlyTemplate = "script-src 'self' 'strict-dynamic' $NONCE;connect-src 'self' ws://$ROOT_PATH wss://$ROOT_PATH;"
+ cfg.AppURL = "http://localhost:3000/"
+ })
+}
+
func TestMiddlewareContext(t *testing.T) {
const noCache = "no-cache"
@@ -770,7 +811,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func(
sc.m = web.New()
sc.m.Use(AddDefaultResponseHeaders(cfg))
- sc.m.UseMiddleware(AddCSPHeader(cfg, logger))
+ sc.m.UseMiddleware(ContentSecurityPolicy(cfg, logger))
sc.m.UseMiddleware(web.Renderer(viewsPath, "[[", "]]"))
sc.mockSQLStore = dbtest.NewFakeDB()
diff --git a/pkg/middleware/testing.go b/pkg/middleware/testing.go
index e8feb484d970..a091f9118fef 100644
--- a/pkg/middleware/testing.go
+++ b/pkg/middleware/testing.go
@@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/services/apikey/apikeytest"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/contexthandler"
+ "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
"github.com/grafana/grafana/pkg/services/login/loginservice"
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/user/usertest"
@@ -77,7 +78,11 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
sc.resp = httptest.NewRecorder()
req, err := http.NewRequest(method, url, nil)
require.NoError(sc.t, err)
- sc.req = req
+
+ reqCtx := &models.ReqContext{
+ Context: web.FromContext(req.Context()),
+ }
+ sc.req = req.WithContext(ctxkey.Set(req.Context(), reqCtx))
return sc
}
@@ -95,7 +100,11 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
}
req.URL.RawQuery = q.Encode()
require.NoError(sc.t, err)
- sc.req = req
+
+ reqCtx := &models.ReqContext{
+ Context: web.FromContext(req.Context()),
+ }
+ sc.req = req.WithContext(ctxkey.Set(req.Context(), reqCtx))
return sc
}
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index 09250fd57811..546974bbe8c8 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -263,7 +263,11 @@ type Cfg struct {
// CSPEnabled toggles Content Security Policy support.
CSPEnabled bool
// CSPTemplate contains the Content Security Policy template.
- CSPTemplate string
+ CSPTemplate string
+ // CSPReportEnabled toggles Content Security Policy Report Only support.
+ CSPReportOnlyEnabled bool
+ // CSPReportOnlyTemplate contains the Content Security Policy Report Only template.
+ CSPReportOnlyTemplate string
AngularSupportEnabled bool
TempDataLifetime time.Duration
@@ -1285,9 +1289,19 @@ 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.AngularSupportEnabled = security.Key("angular_support_enabled").MustBool(true)
cfg.CSPEnabled = security.Key("content_security_policy").MustBool(false)
cfg.CSPTemplate = security.Key("content_security_policy_template").MustString("")
- cfg.AngularSupportEnabled = security.Key("angular_support_enabled").MustBool(true)
+ cfg.CSPReportOnlyEnabled = security.Key("content_security_policy_report_only").MustBool(false)
+ cfg.CSPReportOnlyTemplate = security.Key("content_security_policy_report_only_template").MustString("")
+
+ if cfg.CSPEnabled && cfg.CSPTemplate == "" {
+ return fmt.Errorf("enabling content_security_policy requires a content_security_policy_template configuration")
+ }
+
+ if cfg.CSPReportOnlyEnabled && cfg.CSPReportOnlyTemplate == "" {
+ return fmt.Errorf("enabling content_security_policy_report_only requires a content_security_policy_report_only_template configuration")
+ }
// read data source proxy whitelist
DataProxyWhiteList = make(map[string]bool)