AuthZ: add headers for IP range AC checks for cloud data sources (#80208)

* add feature toggle

* add a middleware that appens headers for IP range AC

* sort imports

* sign IP range header and only append it if the request is going to allow listed data sources

* sign a random generated string instead of IP, also change the name of the middleware to make it more generic

* remove the DS IP range AC options from the config file; remove unwanted change

* add test

* sanitize the URLs when comparing

* cleanup and fixes

* check if X-Real-Ip is present, and set the internal request header if it is not present

* use split string function from the util package
This commit is contained in:
Ieva 2024-01-31 17:09:24 +00:00 committed by GitHub
parent e00aba0ce5
commit c310a20966
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 284 additions and 0 deletions

View File

@ -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)
}

View File

@ -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")
})
}

View File

@ -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) {

View File

@ -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)