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