diff --git a/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go new file mode 100644 index 00000000000..f5c62396571 --- /dev/null +++ b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go @@ -0,0 +1,136 @@ +package clientmiddleware + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "path" + + "github.com/google/uuid" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" +) + +const GrafanaRequestID = "X-Grafana-Request-Id" +const GrafanaSignedRequestID = "X-Grafana-Signed-Request-Id" +const GrafanaInternalRequest = "X-Grafana-Internal-Request" + +// NewHostedGrafanaACHeaderMiddleware creates a new plugins.ClientMiddleware that will +// generate a random request ID, sign it using internal key and populate X-Grafana-Request-ID with the request ID +// and X-Grafana-Signed-Request-ID with signed request ID. We can then use this to verify that the request +// is coming from hosted Grafana and is not an external request. This is used for IP range access control. +func NewHostedGrafanaACHeaderMiddleware(cfg *setting.Cfg) plugins.ClientMiddleware { + return plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client { + return &HostedGrafanaACHeaderMiddleware{ + next: next, + log: log.New("ip_header_middleware"), + cfg: cfg, + } + }) +} + +type HostedGrafanaACHeaderMiddleware struct { + next plugins.Client + log log.Logger + cfg *setting.Cfg +} + +func (m *HostedGrafanaACHeaderMiddleware) applyGrafanaRequestIDHeader(ctx context.Context, pCtx backend.PluginContext, h backend.ForwardHTTPHeaders) { + // if request is not for a datasource, skip the middleware + if h == nil || pCtx.DataSourceInstanceSettings == nil { + return + } + + // Check if the request is for a datasource that is allowed to have the header + target := pCtx.DataSourceInstanceSettings.URL + + foundMatch := false + for _, allowedURL := range m.cfg.IPRangeACAllowedURLs { + if path.Clean(allowedURL) == path.Clean(target) { + foundMatch = true + break + } + } + if !foundMatch { + m.log.Debug("Data source URL not among the allow-listed URLs", "url", target) + return + } + + // Generate a new Grafana request ID and sign it with the secret key + uid, err := uuid.NewRandom() + if err != nil { + m.log.Debug("Failed to generate Grafana request ID", "error", err) + return + } + grafanaRequestID := uid.String() + + hmac := hmac.New(sha256.New, []byte(m.cfg.IPRangeACSecretKey)) + if _, err := hmac.Write([]byte(grafanaRequestID)); err != nil { + m.log.Debug("Failed to sign IP range access control header", "error", err) + return + } + signedGrafanaRequestID := hex.EncodeToString(hmac.Sum(nil)) + h.SetHTTPHeader(GrafanaSignedRequestID, signedGrafanaRequestID) + h.SetHTTPHeader(GrafanaRequestID, grafanaRequestID) + + reqCtx := contexthandler.FromContext(ctx) + if reqCtx != nil && reqCtx.Req != nil { + remoteAddress := web.RemoteAddr(reqCtx.Req) + if remoteAddress != "" { + return + } + } + h.SetHTTPHeader(GrafanaInternalRequest, "true") +} + +func (m *HostedGrafanaACHeaderMiddleware) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + if req == nil { + return m.next.QueryData(ctx, req) + } + + m.applyGrafanaRequestIDHeader(ctx, req.PluginContext, req) + + return m.next.QueryData(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + if req == nil { + return m.next.CallResource(ctx, req, sender) + } + + m.applyGrafanaRequestIDHeader(ctx, req.PluginContext, req) + + return m.next.CallResource(ctx, req, sender) +} + +func (m *HostedGrafanaACHeaderMiddleware) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + if req == nil { + return m.next.CheckHealth(ctx, req) + } + + m.applyGrafanaRequestIDHeader(ctx, req.PluginContext, req) + + return m.next.CheckHealth(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) CollectMetrics(ctx context.Context, req *backend.CollectMetricsRequest) (*backend.CollectMetricsResult, error) { + return m.next.CollectMetrics(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { + return m.next.SubscribeStream(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { + return m.next.PublishStream(ctx, req) +} + +func (m *HostedGrafanaACHeaderMiddleware) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error { + return m.next.RunStream(ctx, req, sender) +} diff --git a/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go new file mode 100644 index 00000000000..4ef2ef76d83 --- /dev/null +++ b/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware_test.go @@ -0,0 +1,130 @@ +package clientmiddleware + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/plugins/manager/client/clienttest" + "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" +) + +func Test_HostedGrafanaACHeaderMiddleware(t *testing.T) { + t.Run("Should set Grafana request ID headers if the data source URL is in the allow list", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.IPRangeACAllowedURLs = []string{"https://logs.grafana.net"} + cfg.IPRangeACSecretKey = "secret" + cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg))) + + ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ + Context: &web.Context{Req: &http.Request{ + Header: map[string][]string{"X-Real-Ip": {"1.2.3.4"}}, + }}, + SignedInUser: &user.SignedInUser{}, + }) + + err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + URL: "https://logs.grafana.net", + }, + }, + }, nopCallResourceSender) + require.NoError(t, err) + + require.Len(t, cdt.CallResourceReq.Headers[GrafanaRequestID], 1) + require.Len(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID], 1) + + requestID := cdt.CallResourceReq.Headers[GrafanaRequestID][0] + + instance := hmac.New(sha256.New, []byte(cfg.IPRangeACSecretKey)) + _, err = instance.Write([]byte(requestID)) + require.NoError(t, err) + computed := hex.EncodeToString(instance.Sum(nil)) + + require.Equal(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID][0], computed) + + // Internal header should not be set + require.Len(t, cdt.CallResourceReq.Headers[GrafanaInternalRequest], 0) + }) + + t.Run("Should not set Grafana request ID headers if the data source URL is not in the allow list", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.IPRangeACAllowedURLs = []string{"https://logs.grafana.net"} + cfg.IPRangeACSecretKey = "secret" + cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg))) + + ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ + Context: &web.Context{Req: &http.Request{}}, + SignedInUser: &user.SignedInUser{}, + }) + + err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + URL: "https://logs.not-grafana.net", + }, + }, + }, nopCallResourceSender) + require.NoError(t, err) + + require.Len(t, cdt.CallResourceReq.Headers[GrafanaRequestID], 0) + require.Len(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID], 0) + }) + + t.Run("Should set Grafana request ID headers if a sanitized data source URL is in the allow list", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.IPRangeACAllowedURLs = []string{"https://logs.GRAFANA.net/"} + cfg.IPRangeACSecretKey = "secret" + cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg))) + + ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ + Context: &web.Context{Req: &http.Request{}}, + SignedInUser: &user.SignedInUser{}, + }) + + err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + URL: "https://logs.grafana.net/abc/../", + }, + }, + }, nopCallResourceSender) + require.NoError(t, err) + + require.Len(t, cdt.CallResourceReq.Headers[GrafanaRequestID], 0) + require.Len(t, cdt.CallResourceReq.Headers[GrafanaSignedRequestID], 0) + }) + + t.Run("Should set Grafana internal request header if the request is internal (doesn't have X-Real-IP header set)", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.IPRangeACAllowedURLs = []string{"https://logs.grafana.net"} + cfg.IPRangeACSecretKey = "secret" + cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(NewHostedGrafanaACHeaderMiddleware(cfg))) + + ctx := context.WithValue(context.Background(), ctxkey.Key{}, &contextmodel.ReqContext{ + Context: &web.Context{Req: &http.Request{}}, + SignedInUser: &user.SignedInUser{}, + }) + + err := cdt.Decorator.CallResource(ctx, &backend.CallResourceRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + URL: "https://logs.grafana.net", + }, + }, + }, nopCallResourceSender) + require.NoError(t, err) + require.Equal(t, cdt.CallResourceReq.Headers[GrafanaInternalRequest][0], "true") + }) +} diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index 6edee03042d..0826cfade83 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -181,6 +181,10 @@ func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthToken middlewares = append(middlewares, clientmiddleware.NewUserHeaderMiddleware()) } + if cfg.IPRangeACEnabled { + middlewares = append(middlewares, clientmiddleware.NewHostedGrafanaACHeaderMiddleware(cfg)) + } + middlewares = append(middlewares, clientmiddleware.NewHTTPClientMiddleware()) if features.IsEnabledGlobally(featuremgmt.FlagPluginsInstrumentationStatusSource) { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index ba5fc64f7a8..58bbc3b6274 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -348,6 +348,11 @@ type Cfg struct { // Number of queries to be executed concurrently. Only for the datasource supports concurrency. ConcurrentQueryCount int + // IP range access control + IPRangeACEnabled bool + IPRangeACAllowedURLs []string + IPRangeACSecretKey string + // SQL Data sources SqlDatasourceMaxOpenConnsDefault int SqlDatasourceMaxIdleConnsDefault int @@ -1200,6 +1205,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { } cfg.readDataSourcesSettings() + cfg.readDataSourceSecuritySettings() cfg.readSqlDataSourceSettings() cfg.Storage = readStorageSettings(iniFile) @@ -1938,6 +1944,14 @@ func (cfg *Cfg) readDataSourcesSettings() { cfg.ConcurrentQueryCount = datasources.Key("concurrent_query_count").MustInt(10) } +func (cfg *Cfg) readDataSourceSecuritySettings() { + datasources := cfg.Raw.Section("datasources.ip_range_security") + cfg.IPRangeACEnabled = datasources.Key("enabled").MustBool(false) + cfg.IPRangeACSecretKey = datasources.Key("secret_key").MustString("") + allowedURLString := datasources.Key("allow_list").MustString("") + cfg.IPRangeACAllowedURLs = util.SplitString(allowedURLString) +} + func (cfg *Cfg) readSqlDataSourceSettings() { sqlDatasources := cfg.Raw.Section("sql_datasources") cfg.SqlDatasourceMaxOpenConnsDefault = sqlDatasources.Key("max_open_conns_default").MustInt(100)