mirror of https://github.com/grafana/grafana
Middleware: Add CSP support (#29740)
* Middleware: Add support for CSP Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com> Co-authored by @iOrcohenpull/30213/head
parent
4ed901e1f9
commit
50b649a869
@ -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" |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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() |
||||
} |
||||
Loading…
Reference in new issue