From f254a37d359709ea800d8fbf633088a7adf8b851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Calisto?= Date: Wed, 16 Nov 2022 17:11:26 +0000 Subject: [PATCH] Middleware: Add CSP Report Only support (#58074) * Middleware: Add CSP Report Only support * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update csp documentation wording * Update conf/sample.ini Co-authored-by: Dave Henderson * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Dave Henderson * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Dave Henderson * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Dave Henderson * Update pkg/middleware/csp.go Co-authored-by: Dave Henderson Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Co-authored-by: Dave Henderson --- conf/defaults.ini | 9 ++ conf/sample.ini | 8 ++ .../setup-grafana/configure-grafana/_index.md | 11 ++- pkg/api/http_server.go | 5 +- pkg/middleware/csp.go | 92 ++++++++++++------- pkg/middleware/middleware_test.go | 43 ++++++++- pkg/middleware/testing.go | 13 ++- pkg/setting/setting.go | 18 +++- 8 files changed, 158 insertions(+), 41 deletions(-) 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)