FS: CSP Middleware (#116819)

* Extract CSP header logic into a seperate middleware

* Add CSP tests

* Improve logging in csp middleware

* Set default CSP in docker compose
This commit is contained in:
Josh Hunt 2026-01-28 12:12:27 +00:00 committed by GitHub
parent 8f5ac4bd08
commit c2efd4da4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 206 additions and 18 deletions

View file

@ -69,7 +69,8 @@ services:
GF_DATABASE_ENSURE_DEFAULT_ORG_AND_USER: false
GF_DEFAULT_APP_MODE: development
GF_DEFAULT_TARGET: frontend-server
GF_SECURITY_CONTENT_SECURITY_POLICY: false
GF_SECURITY_CONTENT_SECURITY_POLICY: true
GF_SECURITY_CONTENT_SECURITY_POLICY_TEMPLATE: "object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
GF_FEATURE_TOGGLES_ENABLE: enableNativeHTTPHistogram
GF_SERVER_CDN_URL: http://localhost:3010
GF_SERVER_ROUTER_LOGGING: true

View file

@ -0,0 +1,51 @@
package frontend
import (
"net/http"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
func CSPMiddleware(cfg *setting.Cfg) web.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCtx := contexthandler.FromContext(r.Context())
logger := reqCtx.Logger
logger.Debug("Applying CSP middleware", "enabled", cfg.CSPEnabled, "report_only_enabled", cfg.CSPReportOnlyEnabled)
// Bail early if CSP is not enabled
if !cfg.CSPEnabled && !cfg.CSPReportOnlyEnabled {
next.ServeHTTP(w, r)
return
}
nonce, err := middleware.GenerateNonce()
if err != nil {
logger.Error("Failed to generate CSP nonce", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Stored on the context so the index handler can put it in the HTML
reqCtx.RequestNonce = nonce
if cfg.CSPEnabled && cfg.CSPTemplate != "" {
logger.Debug("Setting Content-Security-Policy header")
policy := middleware.ReplacePolicyVariables(cfg.CSPTemplate, cfg.AppURL, nonce)
w.Header().Set("Content-Security-Policy", policy)
}
if cfg.CSPReportOnlyEnabled && cfg.CSPReportOnlyTemplate != "" {
logger.Debug("Setting Content-Security-Policy-Report-Only header")
policy := middleware.ReplacePolicyVariables(cfg.CSPReportOnlyTemplate, cfg.AppURL, nonce)
w.Header().Set("Content-Security-Policy-Report-Only", policy)
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -140,6 +140,8 @@ func (s *frontendService) addMiddlewares(m *web.Mux) {
m.UseMiddleware(s.contextMiddleware())
m.UseMiddleware(loggermiddleware.Middleware())
m.UseMiddleware(CSPMiddleware(s.cfg))
m.UseMiddleware(middleware.Recovery(s.cfg, s.license))
}

View file

@ -345,3 +345,152 @@ func TestFrontendService_IndexHooks(t *testing.T) {
assert.Contains(t, body, "window.grafanaBootData")
})
}
func TestFrontendService_CSP(t *testing.T) {
publicDir := setupTestWebAssets(t)
t.Run("should set CSP headers when enabled", func(t *testing.T) {
cfg := &setting.Cfg{
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
AppURL: "https://grafana.example.com/grafana",
CSPEnabled: true,
CSPTemplate: "script-src 'self' $NONCE; style-src 'self' $ROOT_PATH",
}
service := createTestService(t, cfg)
mux := web.New()
service.addMiddlewares(mux)
service.registerRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
// Verify CSP header is set
cspHeader := recorder.Header().Get("Content-Security-Policy")
assert.NotEmpty(t, cspHeader, "CSP header should be set")
assert.Contains(t, cspHeader, "script-src 'self' 'nonce-", "CSP should contain nonce")
assert.Contains(t, cspHeader, "style-src 'self' grafana.example.com/grafana", "CSP should contain root path")
})
t.Run("should set CSP-Report-Only header when enabled", func(t *testing.T) {
cfg := &setting.Cfg{
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
AppURL: "https://grafana.example.com",
CSPReportOnlyEnabled: true,
CSPReportOnlyTemplate: "default-src 'self' $NONCE",
}
service := createTestService(t, cfg)
mux := web.New()
service.addMiddlewares(mux)
service.registerRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
// Verify CSP-Report-Only header is set
cspReportOnlyHeader := recorder.Header().Get("Content-Security-Policy-Report-Only")
assert.NotEmpty(t, cspReportOnlyHeader, "CSP-Report-Only header should be set")
assert.Contains(t, cspReportOnlyHeader, "default-src 'self' 'nonce-", "CSP-Report-Only should contain nonce")
})
t.Run("should set both CSP headers when both enabled", func(t *testing.T) {
cfg := &setting.Cfg{
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
AppURL: "https://grafana.example.com",
CSPEnabled: true,
CSPTemplate: "script-src 'self' $NONCE",
CSPReportOnlyEnabled: true,
CSPReportOnlyTemplate: "default-src 'self'",
}
service := createTestService(t, cfg)
mux := web.New()
service.addMiddlewares(mux)
service.registerRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
// Verify both headers are set
cspHeader := recorder.Header().Get("Content-Security-Policy")
cspReportOnlyHeader := recorder.Header().Get("Content-Security-Policy-Report-Only")
assert.NotEmpty(t, cspHeader, "CSP header should be set")
assert.NotEmpty(t, cspReportOnlyHeader, "CSP-Report-Only header should be set")
})
t.Run("should not set CSP headers when disabled", func(t *testing.T) {
cfg := &setting.Cfg{
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
AppURL: "https://grafana.example.com",
CSPEnabled: false,
}
service := createTestService(t, cfg)
mux := web.New()
service.addMiddlewares(mux)
service.registerRoutes(mux)
req := httptest.NewRequest("GET", "/", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
// Verify CSP headers are not set
cspHeader := recorder.Header().Get("Content-Security-Policy")
cspReportOnlyHeader := recorder.Header().Get("Content-Security-Policy-Report-Only")
assert.Empty(t, cspHeader, "CSP header should not be set when disabled")
assert.Empty(t, cspReportOnlyHeader, "CSP-Report-Only header should not be set when disabled")
})
t.Run("should store nonce in request context", func(t *testing.T) {
cfg := &setting.Cfg{
HTTPPort: "3000",
StaticRootPath: publicDir,
BuildVersion: "10.3.0",
AppURL: "https://grafana.example.com",
CSPEnabled: true,
CSPTemplate: "script-src 'self' $NONCE",
}
service := createTestService(t, cfg)
mux := web.New()
service.addMiddlewares(mux)
var capturedNonce string
mux.Get("/test-nonce", func(w http.ResponseWriter, r *http.Request) {
ctx := contexthandler.FromContext(r.Context())
capturedNonce = ctx.RequestNonce
w.WriteHeader(200)
})
req := httptest.NewRequest("GET", "/test-nonce", nil)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.NotEmpty(t, capturedNonce, "Nonce should be stored in request context")
// Verify the nonce in context matches the one in the CSP header
cspHeader := recorder.Header().Get("Content-Security-Policy")
assert.Contains(t, cspHeader, "'nonce-"+capturedNonce+"'", "Nonce in header should match context nonce")
})
}

View file

@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/services/contexthandler"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/hooks"
@ -134,18 +133,12 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
return
}
nonce, err := middleware.GenerateNonce()
if err != nil {
p.log.Error("error creating nonce", "err", err)
writer.WriteHeader(500)
return
}
reqCtx := contexthandler.FromContext(ctx)
// TODO -- restructure so the static stuff is under one variable and the rest is dynamic
data := p.data // copy everything
data.Nonce = nonce
// Use nonce generated by CSP middleware
data.Nonce = reqCtx.RequestNonce
data.PublicDashboardAccessToken = reqCtx.PublicDashboardAccessToken
// TODO -- reevaluate with mt authnz
@ -172,14 +165,6 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
})
}
if data.CSPEnabled {
data.CSPContent = middleware.ReplacePolicyVariables(p.data.CSPContent, p.data.AppSubUrl, data.Nonce)
writer.Header().Set("Content-Security-Policy", data.CSPContent)
policy := middleware.ReplacePolicyVariables(p.data.CSPReportOnlyContent, p.data.AppSubUrl, data.Nonce)
writer.Header().Set("Content-Security-Policy-Report-Only", policy)
}
p.runIndexDataHooks(reqCtx, &data)
writer.Header().Set("Content-Type", "text/html; charset=UTF-8")