Check for empty URLs when creating/updating a data source (#42837)

* checks for empty URLs added

* check for TimeSeriesTypeNot to fix InfluxDB alerts

* log a warning when a data frame is ignored

* fix: add brittle Prometheus URL input selector

needs a proper aria-label or test-data-id selector

* test: add URL input aria-label

needs to use the grafana/e2e-selectors package

* test: run ci

* add URL validation for specific data sources, e2e tests

* Update pkg/api/datasource/validation.go

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>

* delete duplicated logs

* delete unnecessary leading newline

Co-authored-by: gillesdemey <gilles.de.mey@gmail.com>
Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
This commit is contained in:
Santiago 2022-01-31 12:39:55 -03:00 committed by GitHub
parent 2053049c40
commit 7ed82ac049
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 66 additions and 27 deletions

View File

@ -1,4 +1,5 @@
import { e2e } from '@grafana/e2e'; import { e2e } from '@grafana/e2e';
import { selectors } from '@grafana/e2e-selectors';
const dataSourceName = 'PromExemplar'; const dataSourceName = 'PromExemplar';
const addDataSource = () => { const addDataSource = () => {
@ -9,6 +10,7 @@ const addDataSource = () => {
form: () => { form: () => {
e2e.components.DataSource.Prometheus.configPage.exemplarsAddButton().click(); e2e.components.DataSource.Prometheus.configPage.exemplarsAddButton().click();
e2e.components.DataSource.Prometheus.configPage.internalLinkSwitch().check({ force: true }); e2e.components.DataSource.Prometheus.configPage.internalLinkSwitch().check({ force: true });
e2e.components.DataSource.DataSourceHttpSettings.urlInput().type('http://prom-url:9090');
e2e.components.DataSourcePicker.inputV2().should('be.visible').click({ force: true }); e2e.components.DataSourcePicker.inputV2().should('be.visible').click({ force: true });
e2e().contains('gdev-tempo').scrollIntoView().should('be.visible').click(); e2e().contains('gdev-tempo').scrollIntoView().should('be.visible').click();

View File

@ -35,6 +35,9 @@ export const Components = {
startValue: 'TestData start value', startValue: 'TestData start value',
}, },
}, },
DataSourceHttpSettings: {
urlInput: 'Datasource HTTP settings url',
},
Jaeger: { Jaeger: {
traceIDInput: 'Trace ID', traceIDInput: 'Trace ID',
}, },

View File

@ -1,6 +1,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { DataSourceSettings, SelectableValue } from '@grafana/data'; import { DataSourceSettings, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { BasicAuthSettings } from './BasicAuthSettings'; import { BasicAuthSettings } from './BasicAuthSettings';
import { HttpProxySettings } from './HttpProxySettings'; import { HttpProxySettings } from './HttpProxySettings';
import { TLSAuthSettings } from './TLSAuthSettings'; import { TLSAuthSettings } from './TLSAuthSettings';
@ -125,6 +126,7 @@ export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = (props) => {
className={inputStyle} className={inputStyle}
placeholder={defaultUrl} placeholder={defaultUrl}
value={dataSourceConfig.url} value={dataSourceConfig.url}
aria-label={selectors.components.DataSource.DataSourceHttpSettings.urlInput}
onChange={(event) => onSettingsChange({ url: event.currentTarget.value })} onChange={(event) => onSettingsChange({ url: event.currentTarget.value })}
/> />
); );

View File

@ -1,17 +1,37 @@
package datasource package datasource
import ( import (
"errors"
"fmt" "fmt"
"net/url" "net/url"
"regexp" "regexp"
"strings" "strings"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/tsdb/mssql" "github.com/grafana/grafana/pkg/tsdb/mssql"
) )
var logger = log.New("datasource") var logger = log.New("datasource")
// requiredURL contains the set of data sources that require a URL.
var requiredURL = map[string]bool{
models.DS_GRAPHITE: true,
models.DS_INFLUXDB: true,
models.DS_INFLUXDB_08: true,
models.DS_ES: true,
models.DS_PROMETHEUS: true,
models.DS_ALERTMANAGER: true,
models.DS_JAEGER: true,
models.DS_LOKI: true,
models.DS_OPENTSDB: true,
models.DS_TEMPO: true,
models.DS_ZIPKIN: true,
models.DS_MYSQL: true,
models.DS_POSTGRES: true,
models.DS_MSSQL: true,
}
// URLValidationError represents an error from validating a data source URL. // URLValidationError represents an error from validating a data source URL.
type URLValidationError struct { type URLValidationError struct {
Err error Err error
@ -40,6 +60,11 @@ var reURL = regexp.MustCompile("^[^:]*://")
// The data source's type and URL must be provided. If successful, the valid URL object is returned, otherwise an // The data source's type and URL must be provided. If successful, the valid URL object is returned, otherwise an
// error is returned. // error is returned.
func ValidateURL(typeName, urlStr string) (*url.URL, error) { func ValidateURL(typeName, urlStr string) (*url.URL, error) {
// Check for empty URLs
if _, exists := requiredURL[typeName]; exists && strings.TrimSpace(urlStr) == "" {
return nil, URLValidationError{Err: errors.New("empty URL string"), URL: ""}
}
var u *url.URL var u *url.URL
var err error var err error
switch strings.ToLower(typeName) { switch strings.ToLower(typeName) {

View File

@ -256,13 +256,9 @@ func (hs *HTTPServer) DeleteDataSourceByName(c *models.ReqContext) response.Resp
}) })
} }
func validateURL(tp string, u string) response.Response { func validateURL(cmdType string, url string) response.Response {
if u != "" { if _, err := datasource.ValidateURL(cmdType, url); err != nil {
if _, err := datasource.ValidateURL(tp, u); err != nil { return response.Error(400, fmt.Sprintf("Validation error, invalid URL: %q", url), err)
datasourcesLogger.Error("Received invalid data source URL as part of data source command",
"url", u)
return response.Error(400, fmt.Sprintf("Validation error, invalid URL: %q", u), err)
}
} }
return nil return nil
@ -274,10 +270,13 @@ func AddDataSource(c *models.ReqContext) response.Response {
if err := web.Bind(c.Req, &cmd); err != nil { if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err) return response.Error(http.StatusBadRequest, "bad request data", err)
} }
datasourcesLogger.Debug("Received command to add data source", "url", cmd.Url) datasourcesLogger.Debug("Received command to add data source", "url", cmd.Url)
cmd.OrgId = c.OrgId cmd.OrgId = c.OrgId
if resp := validateURL(cmd.Type, cmd.Url); resp != nil { if cmd.Url != "" {
return resp if resp := validateURL(cmd.Type, cmd.Url); resp != nil {
return resp
}
} }
if err := bus.Dispatch(c.Req.Context(), &cmd); err != nil { if err := bus.Dispatch(c.Req.Context(), &cmd); err != nil {
@ -306,8 +305,7 @@ func (hs *HTTPServer) UpdateDataSource(c *models.ReqContext) response.Response {
datasourcesLogger.Debug("Received command to update data source", "url", cmd.Url) datasourcesLogger.Debug("Received command to update data source", "url", cmd.Url)
cmd.OrgId = c.OrgId cmd.OrgId = c.OrgId
var err error var err error
cmd.Id, err = strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if cmd.Id, err = strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64); err != nil {
if err != nil {
return response.Error(http.StatusBadRequest, "id is invalid", err) return response.Error(http.StatusBadRequest, "id is invalid", err)
} }
if resp := validateURL(cmd.Type, cmd.Url); resp != nil { if resp := validateURL(cmd.Type, cmd.Url); resp != nil {

View File

@ -564,18 +564,18 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
tests := []*testCase{ tests := []*testCase{
createAuthTest(t, secretsService, models.DS_INFLUXDB_08, authTypePassword, authCheckQuery, false), createAuthTest(t, secretsService, models.DS_INFLUXDB_08, "http://localhost:9090", authTypePassword, authCheckQuery, false),
createAuthTest(t, secretsService, models.DS_INFLUXDB_08, authTypePassword, authCheckQuery, true), createAuthTest(t, secretsService, models.DS_INFLUXDB_08, "http://localhost:9090", authTypePassword, authCheckQuery, true),
createAuthTest(t, secretsService, models.DS_INFLUXDB, authTypePassword, authCheckHeader, true), createAuthTest(t, secretsService, models.DS_INFLUXDB, "http://localhost:9090", authTypePassword, authCheckHeader, true),
createAuthTest(t, secretsService, models.DS_INFLUXDB, authTypePassword, authCheckHeader, false), createAuthTest(t, secretsService, models.DS_INFLUXDB, "http://localhost:9090", authTypePassword, authCheckHeader, false),
createAuthTest(t, secretsService, models.DS_INFLUXDB, authTypeBasic, authCheckHeader, true), createAuthTest(t, secretsService, models.DS_INFLUXDB, "http://localhost:9090", authTypeBasic, authCheckHeader, true),
createAuthTest(t, secretsService, models.DS_INFLUXDB, authTypeBasic, authCheckHeader, false), createAuthTest(t, secretsService, models.DS_INFLUXDB, "http://localhost:9090", authTypeBasic, authCheckHeader, false),
// These two should be enough for any other datasource at the moment. Proxy has special handling // These two should be enough for any other datasource at the moment. Proxy has special handling
// only for Influx, others have the same path and only BasicAuth. Non BasicAuth datasources // only for Influx, others have the same path and only BasicAuth. Non BasicAuth datasources
// do not go through proxy but through TSDB API which is not tested here. // do not go through proxy but through TSDB API which is not tested here.
createAuthTest(t, secretsService, models.DS_ES, authTypeBasic, authCheckHeader, false), createAuthTest(t, secretsService, models.DS_ES, "http://localhost:9200", authTypeBasic, authCheckHeader, false),
createAuthTest(t, secretsService, models.DS_ES, authTypeBasic, authCheckHeader, true), createAuthTest(t, secretsService, models.DS_ES, "http://localhost:9200", authTypeBasic, authCheckHeader, true),
} }
for _, test := range tests { for _, test := range tests {
runDatasourceAuthTest(t, secretsService, test) runDatasourceAuthTest(t, secretsService, test)
@ -900,7 +900,7 @@ const (
authCheckHeader = "header" authCheckHeader = "header"
) )
func createAuthTest(t *testing.T, secretsService secrets.Service, dsType string, authType string, authCheck string, useSecureJsonData bool) *testCase { func createAuthTest(t *testing.T, secretsService secrets.Service, dsType string, url string, authType string, authCheck string, useSecureJsonData bool) *testCase {
ctx := context.Background() ctx := context.Background()
// Basic user:password // Basic user:password
@ -911,6 +911,7 @@ func createAuthTest(t *testing.T, secretsService secrets.Service, dsType string,
Id: 1, Id: 1,
Type: dsType, Type: dsType,
JsonData: simplejson.New(), JsonData: simplejson.New(),
Url: url,
}, },
} }
var message string var message string

View File

@ -13,7 +13,15 @@ const (
DS_INFLUXDB_08 = "influxdb_08" DS_INFLUXDB_08 = "influxdb_08"
DS_ES = "elasticsearch" DS_ES = "elasticsearch"
DS_PROMETHEUS = "prometheus" DS_PROMETHEUS = "prometheus"
DS_ALERTMANAGER = "alertmanager"
DS_JAEGER = "jaeger"
DS_LOKI = "loki"
DS_OPENTSDB = "opentsdb"
DS_TEMPO = "tempo"
DS_ZIPKIN = "zipkin"
DS_MYSQL = "mysql" DS_MYSQL = "mysql"
DS_POSTGRES = "postgres"
DS_MSSQL = "mssql"
DS_ACCESS_DIRECT = "direct" DS_ACCESS_DIRECT = "direct"
DS_ACCESS_PROXY = "proxy" DS_ACCESS_PROXY = "proxy"
DS_ES_OPEN_DISTRO = "grafana-es-open-distro-datasource" DS_ES_OPEN_DISTRO = "grafana-es-open-distro-datasource"

View File

@ -58,13 +58,13 @@ var (
) )
const ( const (
gceAuthentication string = "gce" gceAuthentication = "gce"
jwtAuthentication string = "jwt" jwtAuthentication = "jwt"
metricQueryType string = "metrics" metricQueryType = "metrics"
sloQueryType string = "slo" sloQueryType = "slo"
mqlEditorMode string = "mql" mqlEditorMode = "mql"
crossSeriesReducerDefault string = "REDUCE_NONE" crossSeriesReducerDefault = "REDUCE_NONE"
perSeriesAlignerDefault string = "ALIGN_MEAN" perSeriesAlignerDefault = "ALIGN_MEAN"
) )
func ProvideService(httpClientProvider httpclient.Provider, tracer tracing.Tracer) *Service { func ProvideService(httpClientProvider httpclient.Provider, tracer tracing.Tracer) *Service {