mirror of https://github.com/grafana/grafana.git
				
				
				
			
		
			
				
	
	
		
			356 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			356 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
| package pluginproxy
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"net/http"
 | |
| 	"net/url"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"go.opentelemetry.io/otel/attribute"
 | |
| 
 | |
| 	"github.com/grafana/grafana/pkg/api/datasource"
 | |
| 	"github.com/grafana/grafana/pkg/infra/httpclient"
 | |
| 	glog "github.com/grafana/grafana/pkg/infra/log"
 | |
| 	"github.com/grafana/grafana/pkg/infra/tracing"
 | |
| 	"github.com/grafana/grafana/pkg/models"
 | |
| 	"github.com/grafana/grafana/pkg/plugins"
 | |
| 	"github.com/grafana/grafana/pkg/services/datasources"
 | |
| 	"github.com/grafana/grafana/pkg/services/oauthtoken"
 | |
| 	"github.com/grafana/grafana/pkg/setting"
 | |
| 	"github.com/grafana/grafana/pkg/util"
 | |
| 	"github.com/grafana/grafana/pkg/util/proxyutil"
 | |
| )
 | |
| 
 | |
| var (
 | |
| 	logger = glog.New("data-proxy-log")
 | |
| 	client = newHTTPClient()
 | |
| )
 | |
| 
 | |
| type DataSourceProxy struct {
 | |
| 	ds                 *datasources.DataSource
 | |
| 	ctx                *models.ReqContext
 | |
| 	targetUrl          *url.URL
 | |
| 	proxyPath          string
 | |
| 	matchedRoute       *plugins.Route
 | |
| 	pluginRoutes       []*plugins.Route
 | |
| 	cfg                *setting.Cfg
 | |
| 	clientProvider     httpclient.Provider
 | |
| 	oAuthTokenService  oauthtoken.OAuthTokenService
 | |
| 	dataSourcesService datasources.DataSourceService
 | |
| 	tracer             tracing.Tracer
 | |
| }
 | |
| 
 | |
| type httpClient interface {
 | |
| 	Do(req *http.Request) (*http.Response, error)
 | |
| }
 | |
| 
 | |
| // NewDataSourceProxy creates a new Datasource proxy
 | |
| func NewDataSourceProxy(ds *datasources.DataSource, pluginRoutes []*plugins.Route, ctx *models.ReqContext,
 | |
| 	proxyPath string, cfg *setting.Cfg, clientProvider httpclient.Provider,
 | |
| 	oAuthTokenService oauthtoken.OAuthTokenService, dsService datasources.DataSourceService,
 | |
| 	tracer tracing.Tracer) (*DataSourceProxy, error) {
 | |
| 	targetURL, err := datasource.ValidateURL(ds.Type, ds.Url)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return &DataSourceProxy{
 | |
| 		ds:                 ds,
 | |
| 		pluginRoutes:       pluginRoutes,
 | |
| 		ctx:                ctx,
 | |
| 		proxyPath:          proxyPath,
 | |
| 		targetUrl:          targetURL,
 | |
| 		cfg:                cfg,
 | |
| 		clientProvider:     clientProvider,
 | |
| 		oAuthTokenService:  oAuthTokenService,
 | |
| 		dataSourcesService: dsService,
 | |
| 		tracer:             tracer,
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| func newHTTPClient() httpClient {
 | |
| 	return &http.Client{
 | |
| 		Timeout:   30 * time.Second,
 | |
| 		Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (proxy *DataSourceProxy) HandleRequest() {
 | |
| 	if err := proxy.validateRequest(); err != nil {
 | |
| 		proxy.ctx.JsonApiErr(403, err.Error(), nil)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	proxyErrorLogger := logger.New(
 | |
| 		"userId", proxy.ctx.UserID,
 | |
| 		"orgId", proxy.ctx.OrgID,
 | |
| 		"uname", proxy.ctx.Login,
 | |
| 		"path", proxy.ctx.Req.URL.Path,
 | |
| 		"remote_addr", proxy.ctx.RemoteAddr(),
 | |
| 		"referer", proxy.ctx.Req.Referer(),
 | |
| 	)
 | |
| 
 | |
| 	transport, err := proxy.dataSourcesService.GetHTTPTransport(proxy.ctx.Req.Context(), proxy.ds, proxy.clientProvider)
 | |
| 	if err != nil {
 | |
| 		proxy.ctx.JsonApiErr(400, "Unable to load TLS certificate", err)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	modifyResponse := func(resp *http.Response) error {
 | |
| 		if resp.StatusCode == 401 {
 | |
| 			// The data source rejected the request as unauthorized, convert to 400 (bad request)
 | |
| 			body, err := io.ReadAll(resp.Body)
 | |
| 			if err != nil {
 | |
| 				return fmt.Errorf("failed to read data source response body: %w", err)
 | |
| 			}
 | |
| 			_ = resp.Body.Close()
 | |
| 
 | |
| 			ctxLogger := proxyErrorLogger.FromContext(resp.Request.Context())
 | |
| 			ctxLogger.Info("Authentication to data source failed", "body", string(body), "statusCode",
 | |
| 				resp.StatusCode)
 | |
| 			msg := "Authentication to data source failed"
 | |
| 			*resp = http.Response{
 | |
| 				StatusCode:    400,
 | |
| 				Status:        "Bad Request",
 | |
| 				Body:          io.NopCloser(strings.NewReader(msg)),
 | |
| 				ContentLength: int64(len(msg)),
 | |
| 				Header:        http.Header{},
 | |
| 			}
 | |
| 		}
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	reverseProxy := proxyutil.NewReverseProxy(
 | |
| 		proxyErrorLogger,
 | |
| 		proxy.director,
 | |
| 		proxyutil.WithTransport(transport),
 | |
| 		proxyutil.WithModifyResponse(modifyResponse),
 | |
| 	)
 | |
| 
 | |
| 	proxy.logRequest()
 | |
| 	ctx, span := proxy.tracer.Start(proxy.ctx.Req.Context(), "datasource reverse proxy")
 | |
| 	defer span.End()
 | |
| 
 | |
| 	proxy.ctx.Req = proxy.ctx.Req.WithContext(ctx)
 | |
| 
 | |
| 	span.SetAttributes("datasource_name", proxy.ds.Name, attribute.Key("datasource_name").String(proxy.ds.Name))
 | |
| 	span.SetAttributes("datasource_type", proxy.ds.Type, attribute.Key("datasource_type").String(proxy.ds.Type))
 | |
| 	span.SetAttributes("user", proxy.ctx.SignedInUser.Login, attribute.Key("user").String(proxy.ctx.SignedInUser.Login))
 | |
| 	span.SetAttributes("org_id", proxy.ctx.SignedInUser.OrgID, attribute.Key("org_id").Int64(proxy.ctx.SignedInUser.OrgID))
 | |
| 
 | |
| 	proxy.addTraceFromHeaderValue(span, "X-Panel-Id", "panel_id")
 | |
| 	proxy.addTraceFromHeaderValue(span, "X-Dashboard-Id", "dashboard_id")
 | |
| 
 | |
| 	proxy.tracer.Inject(ctx, proxy.ctx.Req.Header, span)
 | |
| 
 | |
| 	reverseProxy.ServeHTTP(proxy.ctx.Resp, proxy.ctx.Req)
 | |
| }
 | |
| 
 | |
| func (proxy *DataSourceProxy) addTraceFromHeaderValue(span tracing.Span, headerName string, tagName string) {
 | |
| 	panelId := proxy.ctx.Req.Header.Get(headerName)
 | |
| 	dashId, err := strconv.Atoi(panelId)
 | |
| 	if err == nil {
 | |
| 		span.SetAttributes(tagName, dashId, attribute.Key(tagName).Int(dashId))
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (proxy *DataSourceProxy) director(req *http.Request) {
 | |
| 	req.URL.Scheme = proxy.targetUrl.Scheme
 | |
| 	req.URL.Host = proxy.targetUrl.Host
 | |
| 	req.Host = proxy.targetUrl.Host
 | |
| 
 | |
| 	reqQueryVals := req.URL.Query()
 | |
| 
 | |
| 	ctxLogger := logger.FromContext(req.Context())
 | |
| 
 | |
| 	switch proxy.ds.Type {
 | |
| 	case datasources.DS_INFLUXDB_08:
 | |
| 		password, err := proxy.dataSourcesService.DecryptedPassword(req.Context(), proxy.ds)
 | |
| 		if err != nil {
 | |
| 			ctxLogger.Error("Error interpolating proxy url", "error", err)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		req.URL.RawPath = util.JoinURLFragments(proxy.targetUrl.Path, "db/"+proxy.ds.Database+"/"+proxy.proxyPath)
 | |
| 		reqQueryVals.Add("u", proxy.ds.User)
 | |
| 		reqQueryVals.Add("p", password)
 | |
| 		req.URL.RawQuery = reqQueryVals.Encode()
 | |
| 	case datasources.DS_INFLUXDB:
 | |
| 		password, err := proxy.dataSourcesService.DecryptedPassword(req.Context(), proxy.ds)
 | |
| 		if err != nil {
 | |
| 			ctxLogger.Error("Error interpolating proxy url", "error", err)
 | |
| 			return
 | |
| 		}
 | |
| 		req.URL.RawPath = util.JoinURLFragments(proxy.targetUrl.Path, proxy.proxyPath)
 | |
| 		req.URL.RawQuery = reqQueryVals.Encode()
 | |
| 		if !proxy.ds.BasicAuth {
 | |
| 			req.Header.Set(
 | |
| 				"Authorization",
 | |
| 				util.GetBasicAuthHeader(proxy.ds.User, password),
 | |
| 			)
 | |
| 		}
 | |
| 	default:
 | |
| 		req.URL.RawPath = util.JoinURLFragments(proxy.targetUrl.Path, proxy.proxyPath)
 | |
| 	}
 | |
| 
 | |
| 	unescapedPath, err := url.PathUnescape(req.URL.RawPath)
 | |
| 	if err != nil {
 | |
| 		ctxLogger.Error("Failed to unescape raw path", "rawPath", req.URL.RawPath, "error", err)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	req.URL.Path = unescapedPath
 | |
| 
 | |
| 	if proxy.ds.BasicAuth {
 | |
| 		password, err := proxy.dataSourcesService.DecryptedBasicAuthPassword(req.Context(), proxy.ds)
 | |
| 		if err != nil {
 | |
| 			ctxLogger.Error("Error interpolating proxy url", "error", err)
 | |
| 			return
 | |
| 		}
 | |
| 		req.Header.Set("Authorization", util.GetBasicAuthHeader(proxy.ds.BasicAuthUser,
 | |
| 			password))
 | |
| 	}
 | |
| 
 | |
| 	dsAuth := req.Header.Get("X-DS-Authorization")
 | |
| 	if len(dsAuth) > 0 {
 | |
| 		req.Header.Del("X-DS-Authorization")
 | |
| 		req.Header.Set("Authorization", dsAuth)
 | |
| 	}
 | |
| 
 | |
| 	applyUserHeader(proxy.cfg.SendUserHeader, req, proxy.ctx.SignedInUser)
 | |
| 
 | |
| 	proxyutil.ClearCookieHeader(req, proxy.ds.AllowedCookies(), []string{proxy.cfg.LoginCookieName})
 | |
| 	req.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion))
 | |
| 
 | |
| 	jsonData := make(map[string]interface{})
 | |
| 	if proxy.ds.JsonData != nil {
 | |
| 		jsonData, err = proxy.ds.JsonData.Map()
 | |
| 		if err != nil {
 | |
| 			ctxLogger.Error("Failed to get json data as map", "jsonData", proxy.ds.JsonData, "error", err)
 | |
| 			return
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if proxy.matchedRoute != nil {
 | |
| 		decryptedValues, err := proxy.dataSourcesService.DecryptedValues(req.Context(), proxy.ds)
 | |
| 		if err != nil {
 | |
| 			ctxLogger.Error("Error interpolating proxy url", "error", err)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		ApplyRoute(req.Context(), req, proxy.proxyPath, proxy.matchedRoute, DSInfo{
 | |
| 			ID:                      proxy.ds.Id,
 | |
| 			Updated:                 proxy.ds.Updated,
 | |
| 			JSONData:                jsonData,
 | |
| 			DecryptedSecureJSONData: decryptedValues,
 | |
| 		}, proxy.cfg)
 | |
| 	}
 | |
| 
 | |
| 	if proxy.oAuthTokenService.IsOAuthPassThruEnabled(proxy.ds) {
 | |
| 		if token := proxy.oAuthTokenService.GetCurrentOAuthToken(req.Context(), proxy.ctx.SignedInUser); token != nil {
 | |
| 			req.Header.Set("Authorization", fmt.Sprintf("%s %s", token.Type(), token.AccessToken))
 | |
| 
 | |
| 			idToken, ok := token.Extra("id_token").(string)
 | |
| 			if ok && idToken != "" {
 | |
| 				req.Header.Set("X-ID-Token", idToken)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (proxy *DataSourceProxy) validateRequest() error {
 | |
| 	if !checkWhiteList(proxy.ctx, proxy.targetUrl.Host) {
 | |
| 		return errors.New("target URL is not a valid target")
 | |
| 	}
 | |
| 
 | |
| 	if proxy.ds.Type == datasources.DS_ES {
 | |
| 		if proxy.ctx.Req.Method == "DELETE" {
 | |
| 			return errors.New("deletes not allowed on proxied Elasticsearch datasource")
 | |
| 		}
 | |
| 		if proxy.ctx.Req.Method == "PUT" {
 | |
| 			return errors.New("puts not allowed on proxied Elasticsearch datasource")
 | |
| 		}
 | |
| 		if proxy.ctx.Req.Method == "POST" && proxy.proxyPath != "_msearch" {
 | |
| 			return errors.New("posts not allowed on proxied Elasticsearch datasource except on /_msearch")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// found route if there are any
 | |
| 	for _, route := range proxy.pluginRoutes {
 | |
| 		// method match
 | |
| 		if route.Method != "" && route.Method != "*" && route.Method != proxy.ctx.Req.Method {
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// route match
 | |
| 		if !strings.HasPrefix(proxy.proxyPath, route.Path) {
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		if route.ReqRole.IsValid() {
 | |
| 			if !proxy.ctx.HasUserRole(route.ReqRole) {
 | |
| 				return errors.New("plugin proxy route access denied")
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		proxy.matchedRoute = route
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	// Trailing validation below this point for routes that were not matched
 | |
| 	if proxy.ds.Type == datasources.DS_PROMETHEUS {
 | |
| 		if proxy.ctx.Req.Method == "DELETE" {
 | |
| 			return errors.New("non allow-listed DELETEs not allowed on proxied Prometheus datasource")
 | |
| 		}
 | |
| 		if proxy.ctx.Req.Method == "PUT" {
 | |
| 			return errors.New("non allow-listed PUTs not allowed on proxied Prometheus datasource")
 | |
| 		}
 | |
| 		if proxy.ctx.Req.Method == "POST" {
 | |
| 			return errors.New("non allow-listed POSTs not allowed on proxied Prometheus datasource")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (proxy *DataSourceProxy) logRequest() {
 | |
| 	if !proxy.cfg.DataProxyLogging {
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	var body string
 | |
| 	if proxy.ctx.Req.Body != nil {
 | |
| 		buffer, err := io.ReadAll(proxy.ctx.Req.Body)
 | |
| 		if err == nil {
 | |
| 			proxy.ctx.Req.Body = io.NopCloser(bytes.NewBuffer(buffer))
 | |
| 			body = string(buffer)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	ctxLogger := logger.FromContext(proxy.ctx.Req.Context())
 | |
| 	ctxLogger.Info("Proxying incoming request",
 | |
| 		"userid", proxy.ctx.UserID,
 | |
| 		"orgid", proxy.ctx.OrgID,
 | |
| 		"username", proxy.ctx.Login,
 | |
| 		"datasource", proxy.ds.Type,
 | |
| 		"uri", proxy.ctx.Req.RequestURI,
 | |
| 		"method", proxy.ctx.Req.Method,
 | |
| 		"body", body)
 | |
| }
 | |
| 
 | |
| func checkWhiteList(c *models.ReqContext, host string) bool {
 | |
| 	if host != "" && len(setting.DataProxyWhiteList) > 0 {
 | |
| 		if _, exists := setting.DataProxyWhiteList[host]; !exists {
 | |
| 			c.JsonApiErr(403, "Data proxy hostname and ip are not included in whitelist", nil)
 | |
| 			return false
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return true
 | |
| }
 |