mirror of
https://github.com/grafana/grafana.git
synced 2025-07-31 07:12:25 +08:00
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:
@ -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)
|
||||||
|
}
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
@ -181,6 +181,10 @@ func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthToken
|
|||||||
middlewares = append(middlewares, clientmiddleware.NewUserHeaderMiddleware())
|
middlewares = append(middlewares, clientmiddleware.NewUserHeaderMiddleware())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.IPRangeACEnabled {
|
||||||
|
middlewares = append(middlewares, clientmiddleware.NewHostedGrafanaACHeaderMiddleware(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
middlewares = append(middlewares, clientmiddleware.NewHTTPClientMiddleware())
|
middlewares = append(middlewares, clientmiddleware.NewHTTPClientMiddleware())
|
||||||
|
|
||||||
if features.IsEnabledGlobally(featuremgmt.FlagPluginsInstrumentationStatusSource) {
|
if features.IsEnabledGlobally(featuremgmt.FlagPluginsInstrumentationStatusSource) {
|
||||||
|
@ -348,6 +348,11 @@ type Cfg struct {
|
|||||||
// Number of queries to be executed concurrently. Only for the datasource supports concurrency.
|
// Number of queries to be executed concurrently. Only for the datasource supports concurrency.
|
||||||
ConcurrentQueryCount int
|
ConcurrentQueryCount int
|
||||||
|
|
||||||
|
// IP range access control
|
||||||
|
IPRangeACEnabled bool
|
||||||
|
IPRangeACAllowedURLs []string
|
||||||
|
IPRangeACSecretKey string
|
||||||
|
|
||||||
// SQL Data sources
|
// SQL Data sources
|
||||||
SqlDatasourceMaxOpenConnsDefault int
|
SqlDatasourceMaxOpenConnsDefault int
|
||||||
SqlDatasourceMaxIdleConnsDefault int
|
SqlDatasourceMaxIdleConnsDefault int
|
||||||
@ -1200,6 +1205,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg.readDataSourcesSettings()
|
cfg.readDataSourcesSettings()
|
||||||
|
cfg.readDataSourceSecuritySettings()
|
||||||
cfg.readSqlDataSourceSettings()
|
cfg.readSqlDataSourceSettings()
|
||||||
|
|
||||||
cfg.Storage = readStorageSettings(iniFile)
|
cfg.Storage = readStorageSettings(iniFile)
|
||||||
@ -1938,6 +1944,14 @@ func (cfg *Cfg) readDataSourcesSettings() {
|
|||||||
cfg.ConcurrentQueryCount = datasources.Key("concurrent_query_count").MustInt(10)
|
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() {
|
func (cfg *Cfg) readSqlDataSourceSettings() {
|
||||||
sqlDatasources := cfg.Raw.Section("sql_datasources")
|
sqlDatasources := cfg.Raw.Section("sql_datasources")
|
||||||
cfg.SqlDatasourceMaxOpenConnsDefault = sqlDatasources.Key("max_open_conns_default").MustInt(100)
|
cfg.SqlDatasourceMaxOpenConnsDefault = sqlDatasources.Key("max_open_conns_default").MustInt(100)
|
||||||
|
Reference in New Issue
Block a user