From 28f9dc1312cd64d142524763fdfb6553bd42968f Mon Sep 17 00:00:00 2001 From: Alexander Emelin Date: Thu, 1 Jul 2021 09:30:09 +0300 Subject: [PATCH] live: add allowed_origins option (#36318) (cherry picked from commit 483418dbb0700709f820489c675d1d6976c4d46e) --- conf/defaults.ini | 4 +++ conf/sample.ini | 4 +++ pkg/services/live/live.go | 53 +++++++++++++++++++++++++--------- pkg/services/live/live_test.go | 40 +++++++++++++++++++++---- pkg/setting/setting.go | 33 +++++++++++++++++++++ 5 files changed, 115 insertions(+), 19 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 319c7f4d8ab..121c5a261d4 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -901,6 +901,10 @@ plugin_catalog_url = https://grafana.com/grafana/plugins/ # tuning. 0 disables Live, -1 means unlimited connections. max_connections = 100 +# allowed_origins is a comma-separated list of origins that can establish connection with Grafana Live. +# If not set then origin will be matched over root_url. Supports globbing: see https://github.com/gobwas/glob. +allowed_origins = + #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] # Instruct headless browser instance to use a default timezone when not provided by Grafana, e.g. when rendering panel image of alert. diff --git a/conf/sample.ini b/conf/sample.ini index e61f94ca5d1..0d4909c8391 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -887,6 +887,10 @@ # tuning. 0 disables Live, -1 means unlimited connections. ;max_connections = 100 +# allowed_origins is a comma-separated list of origins that can establish connection with Grafana Live. +# If not set then origin will be matched over root_url. Supports globbing: see https://github.com/gobwas/glob. +;allowed_origins = + #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] # Instruct headless browser instance to use a default timezone when not provided by Grafana, e.g. when rendering panel image of alert. diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 9d1b08d652b..cc3f80237d5 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -12,6 +12,8 @@ import ( "sync" "time" + "github.com/gobwas/glob" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" @@ -237,21 +239,21 @@ func (g *GrafanaLive) Init() error { return fmt.Errorf("error parsing AppURL %s: %w", g.Cfg.AppURL, err) } + originPatterns := g.Cfg.LiveAllowedOrigins + originGlobs, _ := setting.GetAllowedOriginGlobs(originPatterns) // error already checked on config load. + checkOrigin := getCheckOriginFunc(appURL, originPatterns, originGlobs) + // Use a pure websocket transport. wsHandler := centrifuge.NewWebsocketHandler(node, centrifuge.WebsocketConfig{ ReadBufferSize: 1024, WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - return checkOrigin(r, appURL) - }, + CheckOrigin: checkOrigin, }) pushWSHandler := pushws.NewHandler(g.ManagedStreamRunner, pushws.Config{ ReadBufferSize: 1024, WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - return checkOrigin(r, appURL) - }, + CheckOrigin: checkOrigin, }) g.websocketHandler = func(ctx *models.ReqContext) { @@ -290,21 +292,44 @@ func (g *GrafanaLive) Init() error { return nil } -func checkOrigin(r *http.Request, appURL *url.URL) bool { - origin := r.Header.Get("Origin") - if origin == "" { +func getCheckOriginFunc(appURL *url.URL, originPatterns []string, originGlobs []glob.Glob) func(r *http.Request) bool { + return func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if len(originPatterns) == 1 && originPatterns[0] == "*" { + // fast path for *. + return true + } + ok, err := checkAllowedOrigin(strings.ToLower(origin), appURL, originGlobs) + if err != nil { + logger.Warn("Error parsing request origin", "error", err, "origin", origin) + return false + } + if !ok { + logger.Warn("Request Origin is not authorized", "origin", origin, "appUrl", appURL.String(), "allowedOrigins", strings.Join(originPatterns, ",")) + return false + } return true } +} + +func checkAllowedOrigin(origin string, appURL *url.URL, originGlobs []glob.Glob) (bool, error) { originURL, err := url.Parse(origin) if err != nil { logger.Warn("Failed to parse request origin", "error", err, "origin", origin) - return false + return false, err } - if !strings.EqualFold(originURL.Scheme, appURL.Scheme) || !strings.EqualFold(originURL.Host, appURL.Host) { - logger.Warn("Request Origin is not authorized", "origin", origin, "appUrl", appURL.String()) - return false + if strings.EqualFold(originURL.Scheme, appURL.Scheme) && strings.EqualFold(originURL.Host, appURL.Host) { + return true, nil } - return true + for _, pattern := range originGlobs { + if pattern.Match(origin) { + return true, nil + } + } + return false, nil } func runConcurrentlyIfNeeded(ctx context.Context, semaphore chan struct{}, fn func()) error { diff --git a/pkg/services/live/live_test.go b/pkg/services/live/live_test.go index 06a501bc197..44db4556ddc 100644 --- a/pkg/services/live/live_test.go +++ b/pkg/services/live/live_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" ) @@ -55,10 +57,11 @@ func Test_runConcurrentlyIfNeeded_DeadlineExceeded(t *testing.T) { func TestCheckOrigin(t *testing.T) { testCases := []struct { - name string - origin string - appURL string - success bool + name string + origin string + appURL string + allowedOrigins []string + success bool }{ { name: "empty_origin", @@ -96,6 +99,27 @@ func TestCheckOrigin(t *testing.T) { appURL: "https://example.com", success: true, }, + { + name: "authorized_allowed_origins", + origin: "https://test.example.com", + appURL: "http://localhost:3000/", + allowedOrigins: []string{"https://test.example.com"}, + success: true, + }, + { + name: "authorized_allowed_origins_pattern", + origin: "https://test.example.com", + appURL: "http://localhost:3000/", + allowedOrigins: []string{"https://*.example.com"}, + success: true, + }, + { + name: "authorized_allowed_origins_all", + origin: "https://test.example.com", + appURL: "http://localhost:3000/", + allowedOrigins: []string{"*"}, + success: true, + }, } for _, tc := range testCases { @@ -104,9 +128,15 @@ func TestCheckOrigin(t *testing.T) { t.Parallel() appURL, err := url.Parse(tc.appURL) require.NoError(t, err) + + originGlobs, err := setting.GetAllowedOriginGlobs(tc.allowedOrigins) + require.NoError(t, err) + + checkOrigin := getCheckOriginFunc(appURL, tc.allowedOrigins, originGlobs) + r := httptest.NewRequest("GET", tc.appURL, nil) r.Header.Set("Origin", tc.origin) - require.Equal(t, tc.success, checkOrigin(r, appURL), + require.Equal(t, tc.success, checkOrigin(r), "origin %s, appURL: %s", tc.origin, tc.appURL, ) }) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 8f5097b54fa..64fc2915031 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -17,6 +17,8 @@ import ( "strings" "time" + "github.com/gobwas/glob" + "github.com/prometheus/common/model" ini "gopkg.in/ini.v1" @@ -386,6 +388,9 @@ type Cfg struct { // Grafana Live ws endpoint (per Grafana server instance). 0 disables // Live, -1 means unlimited connections. LiveMaxConnections int + // LiveAllowedOrigins is a set of origins accepted by Live. If not provided + // then Live uses AppURL as the only allowed origin. + LiveAllowedOrigins []string } // IsLiveConfigEnabled returns true if live should be able to save configs to SQL tables @@ -1437,11 +1442,39 @@ func (cfg *Cfg) readDataSourcesSettings() { cfg.DataSourceLimit = datasources.Key("datasource_limit").MustInt(5000) } +func GetAllowedOriginGlobs(originPatterns []string) ([]glob.Glob, error) { + var originGlobs []glob.Glob + allowedOrigins := originPatterns + for _, originPattern := range allowedOrigins { + g, err := glob.Compile(originPattern) + if err != nil { + return nil, fmt.Errorf("error parsing origin pattern: %v", err) + } + originGlobs = append(originGlobs, g) + } + return originGlobs, nil +} + func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error { section := iniFile.Section("live") cfg.LiveMaxConnections = section.Key("max_connections").MustInt(100) if cfg.LiveMaxConnections < -1 { return fmt.Errorf("unexpected value %d for [live] max_connections", cfg.LiveMaxConnections) } + + var originPatterns []string + allowedOrigins := section.Key("allowed_origins").MustString("") + for _, originPattern := range strings.Split(allowedOrigins, ",") { + originPattern = strings.TrimSpace(originPattern) + if originPattern == "" { + continue + } + originPatterns = append(originPatterns, originPattern) + } + _, err := GetAllowedOriginGlobs(originPatterns) + if err != nil { + return err + } + cfg.LiveAllowedOrigins = originPatterns return nil }