mirror of https://github.com/grafana/grafana
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 packagepull/81468/head
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") |
||||
}) |
||||
} |
||||
Loading…
Reference in new issue