Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Middleware: Add CSP Report Only support #58074

Merged
merged 9 commits into from Nov 16, 2022
9 changes: 9 additions & 0 deletions conf/defaults.ini
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions conf/sample.ini
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion docs/sources/setup-grafana/configure-grafana/_index.md
Expand Up @@ -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.

<hr />

Expand Down
5 changes: 4 additions & 1 deletion pkg/api/http_server.go
Expand Up @@ -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)
Expand Down
92 changes: 58 additions & 34 deletions pkg/middleware/csp.go
Expand Up @@ -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
}
43 changes: 42 additions & 1 deletion pkg/middleware/middleware_test.go
Expand Up @@ -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"

Expand Down Expand Up @@ -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()
Expand Down
13 changes: 11 additions & 2 deletions pkg/middleware/testing.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
18 changes: 16 additions & 2 deletions pkg/setting/setting.go
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down