mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06: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:
parent
e00aba0ce5
commit
c310a20966
@ -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())
|
||||
}
|
||||
|
||||
if cfg.IPRangeACEnabled {
|
||||
middlewares = append(middlewares, clientmiddleware.NewHostedGrafanaACHeaderMiddleware(cfg))
|
||||
}
|
||||
|
||||
middlewares = append(middlewares, clientmiddleware.NewHTTPClientMiddleware())
|
||||
|
||||
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.
|
||||
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)
|
||||
|
Loading…
Reference in New Issue
Block a user