diff --git a/conf/defaults.ini b/conf/defaults.ini index 679a6a88eb7..306c625d980 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -490,6 +490,10 @@ enabled = false enabled = true interval_seconds = 10 +#If both are set, basic auth will be required for the metrics endpoint. +basic_auth_username = +basic_auth_password = + # Send internal Grafana metrics to graphite [metrics.graphite] # Enable by setting the address setting (ex localhost:2003) diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 8d156e739bf..30ef020a3de 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -454,6 +454,12 @@ Ex `filters = sqlstore:debug` ### enabled Enable metrics reporting. defaults true. Available via HTTP API `/metrics`. +### basic_auth_username +If set configures the username to use for basic authentication on the metrics endpoint. + +### basic_auth_password +If set configures the password to use for basic authentication on the metrics endpoint. + ### interval_seconds Flush/Write interval when sending metrics to external TSDB. Defaults to 10s. diff --git a/pkg/api/basic_auth.go b/pkg/api/basic_auth.go new file mode 100644 index 00000000000..376cfb24c91 --- /dev/null +++ b/pkg/api/basic_auth.go @@ -0,0 +1,19 @@ +package api + +import ( + "crypto/subtle" + macaron "gopkg.in/macaron.v1" +) + +// BasicAuthenticatedRequest parses the provided HTTP request for basic authentication credentials +// and returns true if the provided credentials match the expected username and password. +// Returns false if the request is unauthenticated. +// Uses constant-time comparison in order to mitigate timing attacks. +func BasicAuthenticatedRequest(req macaron.Request, expectedUser, expectedPass string) bool { + user, pass, ok := req.BasicAuth() + if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(expectedUser)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) != 1 { + return false + } + + return true +} diff --git a/pkg/api/basic_auth_test.go b/pkg/api/basic_auth_test.go new file mode 100644 index 00000000000..0b5051c3e2a --- /dev/null +++ b/pkg/api/basic_auth_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "encoding/base64" + "fmt" + "net/http" + "testing" + + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/macaron.v1" +) + +func TestBasicAuthenticatedRequest(t *testing.T) { + expectedUser := "prometheus" + expectedPass := "password" + + Convey("Given a valid set of basic auth credentials", t, func() { + httpReq, err := http.NewRequest("GET", "http://localhost:3000/metrics", nil) + So(err, ShouldBeNil) + req := macaron.Request{ + Request: httpReq, + } + encodedCreds := encodeBasicAuthCredentials(expectedUser, expectedPass) + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", encodedCreds)) + authenticated := BasicAuthenticatedRequest(req, expectedUser, expectedPass) + So(authenticated, ShouldBeTrue) + }) + + Convey("Given an invalid set of basic auth credentials", t, func() { + httpReq, err := http.NewRequest("GET", "http://localhost:3000/metrics", nil) + So(err, ShouldBeNil) + req := macaron.Request{ + Request: httpReq, + } + encodedCreds := encodeBasicAuthCredentials("invaliduser", "invalidpass") + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", encodedCreds)) + authenticated := BasicAuthenticatedRequest(req, expectedUser, expectedPass) + So(authenticated, ShouldBeFalse) + }) +} + +func encodeBasicAuthCredentials(user, pass string) string { + creds := fmt.Sprintf("%s:%s", user, pass) + return base64.StdEncoding.EncodeToString([]byte(creds)) +} diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index ce28e4716ee..d4d7b41bec5 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -245,6 +245,11 @@ func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) { return } + if hs.metricsEndpointBasicAuthEnabled() && !BasicAuthenticatedRequest(ctx.Req, hs.Cfg.MetricsEndpointBasicAuthUsername, hs.Cfg.MetricsEndpointBasicAuthPassword) { + ctx.Resp.WriteHeader(http.StatusUnauthorized) + return + } + promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{}). ServeHTTP(ctx.Resp, ctx.Req.Request) } @@ -299,3 +304,7 @@ func (hs *HTTPServer) mapStatic(m *macaron.Macaron, rootDir string, dir string, }, )) } + +func (hs *HTTPServer) metricsEndpointBasicAuthEnabled() bool { + return hs.Cfg.MetricsEndpointBasicAuthUsername != "" && hs.Cfg.MetricsEndpointBasicAuthPassword != "" +} diff --git a/pkg/api/http_server_test.go b/pkg/api/http_server_test.go new file mode 100644 index 00000000000..0f99ae82db5 --- /dev/null +++ b/pkg/api/http_server_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestHTTPServer(t *testing.T) { + Convey("Given a HTTPServer", t, func() { + ts := &HTTPServer{ + Cfg: setting.NewCfg(), + } + + Convey("Given that basic auth on the metrics endpoint is enabled", func() { + ts.Cfg.MetricsEndpointBasicAuthUsername = "foo" + ts.Cfg.MetricsEndpointBasicAuthPassword = "bar" + + So(ts.metricsEndpointBasicAuthEnabled(), ShouldBeTrue) + }) + + Convey("Given that basic auth on the metrics endpoint is disabled", func() { + ts.Cfg.MetricsEndpointBasicAuthUsername = "" + ts.Cfg.MetricsEndpointBasicAuthPassword = "" + + So(ts.metricsEndpointBasicAuthEnabled(), ShouldBeFalse) + }) + }) +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 76a50eefb09..1417392fdf8 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -219,6 +219,8 @@ type Cfg struct { DisableBruteForceLoginProtection bool TempDataLifetime time.Duration MetricsEndpointEnabled bool + MetricsEndpointBasicAuthUsername string + MetricsEndpointBasicAuthPassword string EnableAlphaPanels bool EnterpriseLicensePath string } @@ -681,6 +683,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs") cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24) cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true) + cfg.MetricsEndpointBasicAuthUsername = iniFile.Section("metrics").Key("basic_auth_username").String() + cfg.MetricsEndpointBasicAuthPassword = iniFile.Section("metrics").Key("basic_auth_password").String() analytics := iniFile.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true)