diff --git a/.changeset/proud-mice-grow.md b/.changeset/proud-mice-grow.md new file mode 100644 index 000000000..0a6ba0181 --- /dev/null +++ b/.changeset/proud-mice-grow.md @@ -0,0 +1,5 @@ +--- +'grafana-infinity-datasource': minor +--- + +Updated allowed hosts configuration to skip when used with base URL diff --git a/pkg/infinity/client.go b/pkg/infinity/client.go index fd43635f2..63b1faa04 100644 --- a/pkg/infinity/client.go +++ b/pkg/infinity/client.go @@ -234,7 +234,7 @@ func (client *Client) req(ctx context.Context, url string, body io.Reader, setti return nil, http.StatusInternalServerError, duration, errorsource.DownstreamError(fmt.Errorf("invalid response received for the URL %s", url), false) } if res.StatusCode >= http.StatusBadRequest { - err = fmt.Errorf("%w. %s", ErrUnsuccessfulHTTPResponseStatus, res.Status) + err = fmt.Errorf("%w. %s", models.ErrUnsuccessfulHTTPResponseStatus, res.Status) // Infinity can query anything and users are responsible for ensuring that endpoint/auth is correct // therefore any incoming error is considered downstream return nil, res.StatusCode, duration, errorsource.DownstreamError(err, false) @@ -249,7 +249,7 @@ func (client *Client) req(ctx context.Context, url string, body io.Reader, setti var out any err := json.Unmarshal(bodyBytes, &out) if err != nil { - err = fmt.Errorf("%w. %w", ErrParsingResponseBodyAsJson, err) + err = fmt.Errorf("%w. %w", models.ErrParsingResponseBodyAsJson, err) err = errorsource.DownstreamError(err, false) logger.Debug("error un-marshaling JSON response", "url", url, "error", err.Error()) } diff --git a/pkg/infinity/errors.go b/pkg/infinity/errors.go deleted file mode 100644 index 325bd2349..000000000 --- a/pkg/infinity/errors.go +++ /dev/null @@ -1,8 +0,0 @@ -package infinity - -import "errors" - -var ( - ErrUnsuccessfulHTTPResponseStatus error = errors.New("unsuccessful HTTP response") - ErrParsingResponseBodyAsJson error = errors.New("unable to parse response body as JSON") -) diff --git a/pkg/models/errors.go b/pkg/models/errors.go new file mode 100644 index 000000000..5a40f44e3 --- /dev/null +++ b/pkg/models/errors.go @@ -0,0 +1,67 @@ +package models + +import ( + "errors" + "fmt" +) + +var ( + // Config errors + ErrMissingAllowedHosts error = errors.New("datasource is missing allowed hosts/URLs. Configure it in the datasource settings page for enhanced security") + // Query errors + ErrEmptyPaginationListFieldName error = &QueryValidationError{field: "pagination_param_list_field_name"} + // Misc errors + ErrUnsuccessfulHTTPResponseStatus error = errors.New("unsuccessful HTTP response") + ErrParsingResponseBodyAsJson error = errors.New("unable to parse response body as JSON") +) + +type ConfigValidationError struct { + Field string +} + +func (e *ConfigValidationError) Error() string { + return fmt.Sprintf("invalid or empty field: %s ", e.Field) +} + +type QueryValidationError struct { + field string +} + +func (e *QueryValidationError) Error() string { + return fmt.Sprintf("invalid or empty field: %s ", e.field) +} + +type macroError struct { + message string + secondaryMessage string + macroName string + field string +} + +func (e *macroError) Error() string { + if e.message == "invalid macro" { + return fmt.Sprintf("invalid %s macro", e.macroName) + } + if e.macroName == "" { + return e.message + } + if e.message == "" { + return fmt.Sprintf("insufficient arguments to %s macro", e.macroName) + } + return fmt.Sprintf("error applying macros to %s field%s. %s", e.field, e.secondaryMessage, e.message) +} + +type queryParsingError struct { + message string + err error +} + +func (e *queryParsingError) Error() string { + if e.err != nil && e.message == "" { + return e.Error() + } + if e.err != nil { + return fmt.Errorf("%s.%w", e.message, e.err).Error() + } + return e.message +} diff --git a/pkg/models/macros.go b/pkg/models/macros.go index 91eaf8bbd..fcb330372 100644 --- a/pkg/models/macros.go +++ b/pkg/models/macros.go @@ -2,7 +2,6 @@ package models import ( "context" - "errors" "fmt" "regexp" "strings" @@ -38,7 +37,7 @@ func InterPolateMacros(queryString string, timeRange backend.TimeRange, pluginCo macros := map[string]macroFunc{ "combineValues": func(query string, args []string) (string, error) { if len(args) <= 3 { - return query, errorsource.DownstreamError(errors.New("insufficient arguments to combineValues macro"), false) + return query, errorsource.DownstreamError(¯oError{macroName: "combineValues"}, false) } if len(args) == 4 && args[3] == "*" { return "", nil @@ -57,7 +56,7 @@ func InterPolateMacros(queryString string, timeRange backend.TimeRange, pluginCo }, "customInterval": func(query string, args []string) (string, error) { if len(args) == 0 { - return query, errorsource.DownstreamError(errors.New("insufficient arguments to customInterval macro"), false) + return query, errorsource.DownstreamError(¯oError{macroName: "customInterval"}, false) } for argI := range args { if argI == len(args)-1 { @@ -66,7 +65,7 @@ func InterPolateMacros(queryString string, timeRange backend.TimeRange, pluginCo if argI%2 != 0 { duration, err := gtime.ParseDuration(args[argI-1]) if err != nil { - return query, errorsource.DownstreamError(errors.New("invalid customInterval macro"), false) + return query, errorsource.DownstreamError(¯oError{message: "invalid macro", macroName: "customInterval"}, false) } if timeRangeInMilliSeconds <= duration.Milliseconds() { return args[argI], nil @@ -107,44 +106,44 @@ func InterPolateMacros(queryString string, timeRange backend.TimeRange, pluginCo func ApplyMacros(ctx context.Context, query Query, timeRange backend.TimeRange, pluginContext backend.PluginContext) (Query, error) { url, err := InterPolateMacros(query.URL, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to url field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "url"} } query.URL = url uql, err := InterPolateMacros(query.UQL, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to uql field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "uql"} } query.UQL = uql groq, err := InterPolateMacros(query.GROQ, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to uql field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "groq"} } query.GROQ = groq data, err := InterPolateMacros(query.Data, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to data field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "data"} } query.Data = data body, err := InterPolateMacros(query.URLOptions.Body, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to body data field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "body data"} } query.URLOptions.Body = body graphqlQuery, err := InterPolateMacros(query.URLOptions.BodyGraphQLQuery, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to body graphql query field. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "body graphql"} } query.URLOptions.BodyGraphQLQuery = graphqlQuery for idx, p := range query.URLOptions.Params { up, err := InterPolateMacros(p.Value, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to url parameter field %s. %s", p.Key, err.Error()) + return query, ¯oError{message: err.Error(), field: "url parameter", secondaryMessage: " " + p.Key} } query.URLOptions.Params[idx].Value = up } @@ -152,14 +151,14 @@ func ApplyMacros(ctx context.Context, query Query, timeRange backend.TimeRange, for idx, cc := range query.ComputedColumns { up, err := InterPolateMacros(cc.Selector, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to computed column %s (alias: %s). %s", cc.Selector, cc.Text, err.Error()) + return query, ¯oError{message: err.Error(), field: "computed column", secondaryMessage: fmt.Sprintf(" %s (alias: %s)", cc.Selector, cc.Text)} } query.ComputedColumns[idx].Selector = up } exp, err := InterPolateMacros(query.FilterExpression, timeRange, pluginContext) if err != nil { - return query, fmt.Errorf("error applying macros to filter expression. %s", err.Error()) + return query, ¯oError{message: err.Error(), field: "filter expression"} } query.FilterExpression = exp diff --git a/pkg/models/macros_test.go b/pkg/models/macros_test.go index 33d597464..71d312beb 100644 --- a/pkg/models/macros_test.go +++ b/pkg/models/macros_test.go @@ -41,7 +41,7 @@ func TestInterPolateCombineValueMacros(t *testing.T) { }, tt.pluginContext) if tt.wantError != nil { require.NotNil(t, err) - require.Equal(t, tt.wantError, err) + assert.Equal(t, tt.wantError.Error(), err.Error()) return } require.Nil(t, err) @@ -84,7 +84,7 @@ func TestInterPolateFromToMacros(t *testing.T) { got, err := models.InterPolateMacros(tt.query, *tr, tt.pluginContext) if tt.wantError != nil { require.NotNil(t, err) - require.Equal(t, tt.wantError, err) + assert.Equal(t, tt.wantError.Error(), err.Error()) return } require.Nil(t, err) @@ -126,7 +126,7 @@ func TestInterPolateCustomIntervalMacros(t *testing.T) { got, err := models.InterPolateMacros(tt.query, backend.TimeRange{From: time.UnixMilli(from), To: time.UnixMilli(to)}, tt.pluginContext) if tt.wantError != nil { require.NotNil(t, err) - require.Equal(t, tt.wantError, err) + assert.Equal(t, tt.wantError.Error(), err.Error()) return } require.Nil(t, err) diff --git a/pkg/models/query.go b/pkg/models/query.go index 95b4947f4..0011c72d6 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -3,7 +3,6 @@ package models import ( "context" "encoding/json" - "errors" "fmt" "strings" @@ -303,12 +302,12 @@ func LoadQuery(ctx context.Context, backendQuery backend.DataQuery, pluginContex var query Query err := json.Unmarshal(backendQuery.JSON, &query) if err != nil { - return query, errorsource.DownstreamError(fmt.Errorf("error while parsing the query json. %w", err), false) + return query, errorsource.DownstreamError(&queryParsingError{message: "error while parsing the query json", err: err}, false) } query = ApplyDefaultsToQuery(ctx, query, settings) if query.PageMode == PaginationModeList && strings.TrimSpace(query.PageParamListFieldName) == "" { // Downstream error as user input is not correct - return query, errorsource.DownstreamError(errors.New("pagination_param_list_field_name cannot be empty"), false) + return query, errorsource.DownstreamError(ErrEmptyPaginationListFieldName, false) } return ApplyMacros(ctx, query, backendQuery.TimeRange, pluginContext) } diff --git a/pkg/models/settings.go b/pkg/models/settings.go index e6ffd04d2..16a4015a4 100644 --- a/pkg/models/settings.go +++ b/pkg/models/settings.go @@ -3,7 +3,6 @@ package models import ( "context" "encoding/json" - "errors" "fmt" "net/textproto" "strings" @@ -123,42 +122,52 @@ type InfinitySettings struct { func (s *InfinitySettings) Validate() error { if (s.BasicAuthEnabled || s.AuthenticationMethod == AuthenticationMethodBasic || s.AuthenticationMethod == AuthenticationMethodDigestAuth) && s.Password == "" { - return errors.New("invalid or empty password detected") + return &ConfigValidationError{Field: "password"} } if s.AuthenticationMethod == AuthenticationMethodApiKey && (s.ApiKeyValue == "" || s.ApiKeyKey == "") { - return errors.New("invalid API key specified") + return &ConfigValidationError{Field: "API key"} } if s.AuthenticationMethod == AuthenticationMethodBearerToken && s.BearerToken == "" { - return errors.New("invalid or empty bearer token detected") + return &ConfigValidationError{Field: "Bearer token"} } if s.AuthenticationMethod == AuthenticationMethodAzureBlob { if strings.TrimSpace(s.AzureBlobAccountName) == "" { - return errors.New("invalid/empty azure blob account name") + return &ConfigValidationError{Field: "Azure blob account name"} } if strings.TrimSpace(s.AzureBlobAccountKey) == "" { - return errors.New("invalid/empty azure blob key") + return &ConfigValidationError{Field: "Azure blob key"} } return nil } if s.AuthenticationMethod == AuthenticationMethodAWS && s.AWSSettings.AuthType == AWSAuthTypeKeys { if strings.TrimSpace(s.AWSAccessKey) == "" { - return errors.New("invalid/empty AWS access key") + return &ConfigValidationError{Field: "AWS access key"} } if strings.TrimSpace(s.AWSSecretKey) == "" { - return errors.New("invalid/empty AWS secret key") + return &ConfigValidationError{Field: "AWS secret key"} } return nil } - if s.AuthenticationMethod != AuthenticationMethodNone && len(s.AllowedHosts) < 1 { - return errors.New("configure allowed hosts in the authentication section") - } - if s.HaveSecureHeaders() && len(s.AllowedHosts) < 1 { - return errors.New("configure allowed hosts in the authentication section") + if s.DoesAllowedHostsRequired() && len(s.AllowedHosts) < 1 { + return ErrMissingAllowedHosts } return nil } -func (s *InfinitySettings) HaveSecureHeaders() bool { +func (s *InfinitySettings) DoesAllowedHostsRequired() bool { + // If base url is configured, there is no need for allowed hosts + if strings.TrimSpace(s.URL) != "" { + return false + } + // If there is specific authentication mechanism (except none and azure blob), then allowed hosts required + if s.AuthenticationMethod != "" && s.AuthenticationMethod != AuthenticationMethodNone && s.AuthenticationMethod != AuthenticationMethodAzureBlob { + return true + } + // If there are any TLS specific settings enabled, then allowed hosts required + if s.TLSAuthWithCACert || s.TLSClientAuth { + return true + } + // If there are custom headers (not generic headers such as Accept, Content Type etc), then allowed hosts required if len(s.CustomHeaders) > 0 { for k := range s.CustomHeaders { if textproto.CanonicalMIMEHeaderKey(k) == "Accept" { @@ -169,7 +178,10 @@ func (s *InfinitySettings) HaveSecureHeaders() bool { } return true } - return false + } + // If there are custom query parameters, then allowed hosts required + if len(s.SecureQueryFields) > 0 { + return true } return false } diff --git a/pkg/models/settings_test.go b/pkg/models/settings_test.go index 8019028dc..db3217f8d 100644 --- a/pkg/models/settings_test.go +++ b/pkg/models/settings_test.go @@ -2,7 +2,6 @@ package models_test import ( "context" - "errors" "testing" "github.com/grafana/grafana-infinity-datasource/pkg/models" @@ -335,59 +334,96 @@ func TestInfinitySettings_Validate(t *testing.T) { wantErr error }{ { + name: "should not error when custom settings", + settings: models.InfinitySettings{}, + }, + { + name: "Should not error when no authentication method specified", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone}, }, { + name: "Should error when missing allowed hosts with custom headers", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B"}}, - wantErr: errors.New("configure allowed hosts in the authentication section"), + wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when missing allowed hosts with custom headers and empty Accept", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B", "Accept": ""}}, - wantErr: errors.New("configure allowed hosts in the authentication section"), + wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when missing allowed hosts with custom headers and empty Content-Type", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B", "Content-Type": ""}}, - wantErr: errors.New("configure allowed hosts in the authentication section"), + wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when missing allowed hosts with custom headers and empty Accept and Content-Type", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B", "Accept": "", "Content-Type": ""}}, - wantErr: errors.New("configure allowed hosts in the authentication section"), + wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when missing allowed hosts with multiple custom headers", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodNone, CustomHeaders: map[string]string{"A": "B", "C": "D"}}, - wantErr: errors.New("configure allowed hosts in the authentication section"), + wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error basic authentication without password", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodBasic}, - wantErr: errors.New("invalid or empty password detected"), + wantErr: &models.ConfigValidationError{Field: "password"}, }, { + name: "Should error basic authentication with password but missing allowed hosts", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodBasic, Password: "123"}, - wantErr: errors.New("configure allowed hosts in the authentication section"), + wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when API key authentication without key", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodApiKey}, - wantErr: errors.New("invalid API key specified"), + wantErr: &models.ConfigValidationError{Field: "API key"}, }, { + name: "Should error when API key authentication with key but without value", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodApiKey, ApiKeyKey: "foo"}, - wantErr: errors.New("invalid API key specified"), + wantErr: &models.ConfigValidationError{Field: "API key"}, }, { + name: "Should error when API key authentication with value but without key", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodApiKey, ApiKeyValue: "bar"}, - wantErr: errors.New("invalid API key specified"), + wantErr: &models.ConfigValidationError{Field: "API key"}, }, { + name: "Should error when API key authentication with key and value but missing allowed hosts", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodApiKey, ApiKeyKey: "foo", ApiKeyValue: "bar"}, - wantErr: errors.New("configure allowed hosts in the authentication section"), + wantErr: models.ErrMissingAllowedHosts, }, { + name: "Should error when bearer token authentication without token", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodBearerToken}, - wantErr: errors.New("invalid or empty bearer token detected"), + wantErr: &models.ConfigValidationError{Field: "Bearer token"}, }, { + name: "Should error when there are no allowed hosts and bearer token authentication with token but missing allowed hosts", settings: models.InfinitySettings{AuthenticationMethod: models.AuthenticationMethodBearerToken, BearerToken: "foo"}, - wantErr: errors.New("configure allowed hosts in the authentication section"), + wantErr: models.ErrMissingAllowedHosts, + }, + { + name: "Should error when there are no allowed hosts and secure query fields", + settings: models.InfinitySettings{SecureQueryFields: map[string]string{"foo": "bar"}}, + wantErr: models.ErrMissingAllowedHosts, + }, + { + name: "Should not error when there are no allowed hosts and valid URL", + settings: models.InfinitySettings{URL: "https://foo"}, + }, + { + name: "Should error when there are no allowed hosts and TLS client authentication", + settings: models.InfinitySettings{TLSClientAuth: true}, + wantErr: models.ErrMissingAllowedHosts, + }, + { + name: "Should error when there are no allowed hosts and TLS cert authentication", + settings: models.InfinitySettings{TLSAuthWithCACert: true}, + wantErr: models.ErrMissingAllowedHosts, }, } for _, tt := range tests { diff --git a/pkg/pluginhost/handler_querydata.go b/pkg/pluginhost/handler_querydata.go index 05c821361..bc87c7961 100644 --- a/pkg/pluginhost/handler_querydata.go +++ b/pkg/pluginhost/handler_querydata.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana-infinity-datasource/pkg/models" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" - "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -103,13 +102,8 @@ func QueryDataQuery(ctx context.Context, query models.Query, infClient infinity. query, _ := infinity.UpdateQueryWithReferenceData(ctx, query, infClient.Settings) switch query.Source { case "url", "azure-blob": - if infClient.Settings.AuthenticationMethod != models.AuthenticationMethodAzureBlob && infClient.Settings.AuthenticationMethod != models.AuthenticationMethodNone && len(infClient.Settings.AllowedHosts) < 1 { - response.Error = errors.New("datasource is missing allowed hosts/URLs. Configure it in the datasource settings page for enhanced security") - response.ErrorSource = backend.ErrorSourceDownstream - return response - } - if infClient.Settings.HaveSecureHeaders() && len(infClient.Settings.AllowedHosts) < 1 { - response.Error = errors.New("datasource is missing allowed hosts/URLs. Configure it in the datasource settings page for enhanced security") + if infClient.Settings.DoesAllowedHostsRequired() && len(infClient.Settings.AllowedHosts) < 1 { + response.Error = models.ErrMissingAllowedHosts response.ErrorSource = backend.ErrorSourceDownstream return response } @@ -130,11 +124,6 @@ func QueryDataQuery(ctx context.Context, query models.Query, infClient infinity. response.ErrorSource = errorsource.SourceError(backend.ErrorSourcePlugin, err, false).Source() return response } - if frame != nil && infClient.Settings.AuthenticationMethod != models.AuthenticationMethodAzureBlob && infClient.Settings.AuthenticationMethod != models.AuthenticationMethodNone && infClient.Settings.AuthenticationMethod != "" && len(infClient.Settings.AllowedHosts) < 1 { - frame.AppendNotices(data.Notice{ - Text: "Datasource is missing allowed hosts/URLs. Configure it in the datasource settings page for enhanced security.", - }) - } if frame != nil { frame, _ = infinity.WrapMetaForRemoteQuery(ctx, infClient.Settings, frame, nil, query) response.Frames = append(response.Frames, frame) diff --git a/pkg/testsuite/handler_querydata_errors_test.go b/pkg/testsuite/handler_querydata_errors_test.go index 32f39a1c8..4ea65ca9a 100644 --- a/pkg/testsuite/handler_querydata_errors_test.go +++ b/pkg/testsuite/handler_querydata_errors_test.go @@ -42,7 +42,7 @@ func TestErrors(t *testing.T) { }, *client, map[string]string{}, backend.PluginContext{}) require.NotNil(t, res.Error) require.Equal(t, backend.ErrorSourceDownstream, res.ErrorSource) - require.ErrorIs(t, res.Error, infinity.ErrUnsuccessfulHTTPResponseStatus) + require.ErrorIs(t, res.Error, models.ErrUnsuccessfulHTTPResponseStatus) require.Equal(t, "error while performing the infinity query. unsuccessful HTTP response. 403 Forbidden", res.Error.Error()) }) t.Run("fail with incorrect response from server", func(t *testing.T) { @@ -58,7 +58,7 @@ func TestErrors(t *testing.T) { }, *client, map[string]string{}, backend.PluginContext{}) require.NotNil(t, res.Error) require.Equal(t, backend.ErrorSourceDownstream, res.ErrorSource) - require.ErrorIs(t, res.Error, infinity.ErrParsingResponseBodyAsJson) + require.ErrorIs(t, res.Error, models.ErrParsingResponseBodyAsJson) require.Equal(t, "error while performing the infinity query. unable to parse response body as JSON. unexpected end of JSON input", res.Error.Error()) }) t.Run("fail with incorrect JSONata", func(t *testing.T) {