Skip to content

Commit

Permalink
allowed hosts and denied hosts
Browse files Browse the repository at this point in the history
  • Loading branch information
yesoreyeram committed Feb 3, 2025
1 parent e6b5890 commit 90fbfb8
Show file tree
Hide file tree
Showing 9 changed files with 211 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .changeset/twelve-taxis-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grafana-infinity-datasource': minor
---

Allow Grafana admins to set allowed hosts / denied hosts via grafana.ini file. [More details](https://grafana.com/docs/plugins/yesoreyeram-infinity-datasource/latest/references/url/)
2 changes: 2 additions & 0 deletions conf/grafana.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[plugin.yesoreyeram-infinity-datasource]
host_deny_list = "foo.com bar.com baz.com"
2 changes: 2 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ services:
volumes:
- ./provisioning/dashboards-actual/:/dashboards/
- ./provisioning:/etc/grafana/provisioning
- ./conf:/grafana-config
- ./dist:/var/lib/grafana/plugins/yesoreyeram-infinity-datasource
environment:
- TERM=linux
- GF_DEFAULT_APP_MODE=development
- GF_PATHS_CONFIG=/grafana-config/grafana.ini
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_SECURITY_ANGULAR_SUPPORT_ENABLED=false
Expand Down
22 changes: 20 additions & 2 deletions docs/sources/references/url.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Note: We suggest adding secure headers only via configuration and not in query.

## Forwarding Grafana meta data as headers / query params

From Infinity plugin version 3.0.0, You will be able to forward grafana meta data such as user id, datasource uid to the outgoing requests via **Custom HTTP Headers** / **URL Query parameters\*** from the datasource settings page. In the datasource **URL** section, you can add any number of custom headers / query parameters with their own values. The values can include following macros which will be interpolated into actual value from the request context.
From Infinity plugin version 3.0.0, You will be able to forward grafana meta data such as user id, datasource uid to the outgoing requests via **Custom HTTP Headers** / **URL Query parameters** from the datasource settings page. In the datasource **URL** section, you can add any number of custom headers / query parameters with their own values. The values can include following macros which will be interpolated into actual value from the request context.

| Macro name | Description |
| --------------------- | ------------------------------------------------------------------- |
Expand All @@ -76,8 +76,26 @@ From Infinity plugin version 3.0.0, You will be able to forward grafana meta dat

> Note: Certain macros such as `${__user.login}` won't be available in the context of alerts, recorded queries, public dashboards etc.
## Allowed Hosts
## Allowed Hosts ( Data source config )

Leaving blank will allow all the hosts. This is by default.

If your data source needs to allow only certain hosts, configure the allowed host names in the config. There can be multiple hosts allowed. Host names are case sensitive and needs to be full host name. Example: `https://en.wikipedia.org/`

## Allowed Hosts ( Grafana config )

If the Grafana admin need to allow only certain hosts to be queried via infinity datasource plugin, they can set grafana.ini file. To set the allowed hosts in the grafana.ini file, add the following section in the grafana.ini file.

```ini
[plugin.yesoreyeram-infinity-datasource]
host_allow_list = "foo.com bar.com baz.com"
```

## Denied Hosts ( Grafana config )

If the Grafana admin need to deny certain hosts to be queried via infinity datasource plugin, they can set grafana.ini file. To set the denied hosts in the grafana.ini file, add the following section in the grafana.ini file.

```ini
[plugin.yesoreyeram-infinity-datasource]
host_deny_list = "foo.com bar.com baz.com"
```
19 changes: 3 additions & 16 deletions pkg/infinity/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ func (client *Client) req(ctx context.Context, pCtx *backend.PluginContext, url
return nil, http.StatusInternalServerError, 0, backend.DownstreamError(errors.New("error preparing request. invalid request constructed"))
}
startTime := time.Now()
if !CanAllowURL(req.URL.String(), settings.AllowedHosts) {
logger.Debug("url is not in the allowed list. make sure to match the base URL with the settings", "url", req.URL.String())
return nil, http.StatusUnauthorized, 0, backend.DownstreamError(errors.New("requested URL is not allowed. To allow this URL, update the datasource config Security -> Allowed Hosts section"))
if reqValidationErr := ValidateRequest(ctx, pCtx, settings, req); reqValidationErr != nil {
logger.Debug("url is not allowed", "url", req.URL.String(), "err", reqValidationErr.Error())
return nil, http.StatusUnauthorized, 0, backend.DownstreamError(reqValidationErr)
}
logger.Debug("requesting URL", "host", req.URL.Hostname(), "url_path", req.URL.Path, "method", req.Method, "type", query.Type)
res, err := client.HttpClient.Do(req)
Expand Down Expand Up @@ -234,19 +234,6 @@ func CanParseAsJSON(queryType models.QueryType, responseHeaders http.Header) boo
return false
}

func CanAllowURL(url string, allowedHosts []string) bool {
allow := false
if len(allowedHosts) == 0 {
return true
}
for _, host := range allowedHosts {
if strings.HasPrefix(url, host) {
return true
}
}
return allow
}

func GetQueryBody(ctx context.Context, query models.Query) io.Reader {
logger := backend.Logger.FromContext(ctx)
var body io.Reader
Expand Down
61 changes: 48 additions & 13 deletions pkg/infinity/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package infinity_test

import (
"context"
"errors"
"fmt"
"net/http"
"testing"
Expand All @@ -10,6 +11,7 @@ import (
"github.com/grafana/grafana-infinity-datasource/pkg/models"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestInfinityClient_GetResults(t *testing.T) {
Expand Down Expand Up @@ -79,43 +81,76 @@ func TestInfinityClient_GetResults(t *testing.T) {
}
}

func TestCanAllowURL(t *testing.T) {
func TestValidateRequest(t *testing.T) {
tests := []struct {
name string
url string
allowedHosts []string
want bool
name string
url string
envAllowedHost string
envDeniedHost string
allowedHosts []string
wantErr error
}{
{
url: "https://foo.com",
want: true,
url: "https://foo.com",
},
{
url: "https://foo.com",
allowedHosts: []string{"https://foo.com"},
want: true,
},
{
url: "https://bar.com",
allowedHosts: []string{"https://foo.com"},
want: false,
wantErr: models.ErrURLNotAllowed,
},
{
name: "should match only case sensitive URL",
url: "https://FOO.com",
allowedHosts: []string{"https://foo.com"},
want: false,
wantErr: models.ErrURLNotAllowed,
},
{
url: "https://bar.com/",
allowedHosts: []string{"https://foo.com/", "https://bar.com/", "https://baz.com/"},
want: true,
},
{
name: "should throw error if host name matches the denied list in the grafana config",
url: "https://FOO.com",
envDeniedHost: "baa.com foo.com bar.com",
wantErr: errors.New("hostname denied via grafana config. hostname FOO.com"),
},
{
name: "should throw error if host name not found in the allowed list in the grafana config",
url: "https://FOO.com",
envAllowedHost: "baa.com foo.co bar.com",
wantErr: errors.New("hostname not allowed via grafana config. hostname FOO.com"),
},
{
name: "should not throw error if host name doesn't match the denied list in the grafana config",
url: "https://FOO.com",
envDeniedHost: "baa.com bar.com",
},
{
name: "should not throw error if host name found in the allowed list in the grafana config",
url: "https://FOO.com",
envAllowedHost: "baa.com foo.com bar.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := infinity.CanAllowURL(tt.url, tt.allowedHosts)
assert.Equal(t, tt.want, got)
if tt.envAllowedHost != "" {
t.Setenv("GF_PLUGIN_HOST_ALLOW_LIST", tt.envAllowedHost)
}
if tt.envDeniedHost != "" {
t.Setenv("GF_PLUGIN_HOST_DENY_LIST", tt.envDeniedHost)
}
req, _ := http.NewRequest(http.MethodGet, tt.url, nil)
gotErr := infinity.ValidateRequest(context.TODO(), &backend.PluginContext{}, models.InfinitySettings{AllowedHosts: tt.allowedHosts}, req)
if tt.wantErr != nil {
require.NotNil(t, gotErr)
assert.Equal(t, tt.wantErr, gotErr)
return
}
require.Nil(t, gotErr)
})
}
}
Expand Down
81 changes: 81 additions & 0 deletions pkg/infinity/request_validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package infinity

import (
"context"
"net/http"
"strings"

"github.com/grafana/grafana-infinity-datasource/pkg/models"
"github.com/grafana/grafana-plugin-sdk-go/backend"
)

// ValidateRequest is a basic implementation of request validator / interceptor
func ValidateRequest(ctx context.Context, pCtx *backend.PluginContext, settings models.InfinitySettings, req *http.Request) error {
hostName := req.URL.Hostname()
if hostName == "" {
return nil
}

//#region denied list of hosts from the grafana config / environment variable
// if the host name of URL found in denied list of hosts grafana config, the URL should be blocked
deniedHostsSettingFromGrafana := getHostNamesFromConfig(ctx, pCtx, "host_deny_list")
if len(deniedHostsSettingFromGrafana) > 0 {
deny := false
for _, deniedHost := range deniedHostsSettingFromGrafana {
if strings.EqualFold(hostName, strings.TrimSpace(deniedHost)) {
deny = true
}
}
if deny {
return models.ErrHostNameDenied(hostName)
}
}
//#endregion

//#region allowed list of hosts from the grafana config / environment variable
// if the host name of URL not found in allowed list of hosts grafana config, the URL should be blocked
allowedHostsSettingFromGrafana := getHostNamesFromConfig(ctx, pCtx, "host_allow_list")
if len(allowedHostsSettingFromGrafana) > 0 {
allow := false
for _, allowedHost := range allowedHostsSettingFromGrafana {
if strings.EqualFold(hostName, strings.TrimSpace(allowedHost)) {
allow = true
}
}
if !allow {
return models.ErrHostNameNotAllowed(hostName)
}
}
//#endregion

//#region allowed list of hosts from the grafana config / environment variable
allowedHostsFromDsConfig := settings.AllowedHosts
if len(allowedHostsFromDsConfig) > 0 {
allow := false
for _, host := range allowedHostsFromDsConfig {
if strings.HasPrefix(req.URL.String(), host) {
allow = true
}
}
if !allow {
return models.ErrURLNotAllowed
}
}
//#endregion

return nil
}

func getHostNamesFromConfig(ctx context.Context, pCtx *backend.PluginContext, key string) (out []string) {
hostNamesConfig := strings.TrimSpace(models.GetGrafanaConfig(ctx, pCtx, key))
if hostNamesConfig == "" {
return out
}
hostNames := strings.Split(hostNamesConfig, " ")
for _, k := range hostNames {
if key := strings.TrimSpace(k); key != "" {
out = append(out, key)
}
}
return out
}
14 changes: 13 additions & 1 deletion pkg/models/errors.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
package models

import "errors"
import (
"errors"
"fmt"
)

var (
ErrUnsuccessfulHTTPResponseStatus error = errors.New("unsuccessful HTTP response")
ErrParsingResponseBodyAsJson error = errors.New("unable to parse response body as JSON")
ErrCreatingHTTPClient error = errors.New("error creating HTTP client")
ErrURLNotAllowed error = errors.New("requested URL is not allowed. To allow this URL, update the datasource config Security -> Allowed Hosts section")
)

func ErrHostNameDenied(hostName string) error {
return fmt.Errorf("hostname denied via grafana config. hostname %s", hostName)
}

func ErrHostNameNotAllowed(hostName string) error {
return fmt.Errorf("hostname not allowed via grafana config. hostname %s", hostName)
}
37 changes: 37 additions & 0 deletions pkg/models/grafana_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package models

import (
"context"
"fmt"
"os"
"strings"

"github.com/grafana/grafana-plugin-sdk-go/backend"
)

// GetGrafanaConfig allow you to retrieve config set via grafana.ini file
func GetGrafanaConfig(ctx context.Context, pCtx *backend.PluginContext, key string) (output string) {
key = strings.TrimSpace(strings.ToUpper(key))
if v := strings.TrimSpace(os.Getenv(fmt.Sprintf("GF_PLUGIN_%s", key))); v != "" {
output = v
}
// if pCtx == nil {
// return output
// }
// pluginId := strings.TrimSpace(strings.ToUpper(pCtx.PluginID))
// if v := strings.TrimSpace(os.Getenv(fmt.Sprintf("GF_PLUGIN_%s_%s", pluginId, key))); v != "" && pluginId != "" {
// output = v
// }
// if pCtx.DataSourceInstanceSettings == nil {
// return output
// }
// dsUID := strings.TrimSpace(strings.ToUpper(pCtx.DataSourceInstanceSettings.UID))
// if v := strings.TrimSpace(os.Getenv(fmt.Sprintf("GF_DS_%s_%s", dsUID, key))); v != "" && dsUID != "" {
// output = v
// }
// caseSensitiveDsUID := strings.TrimSpace(pCtx.DataSourceInstanceSettings.UID)
// if v := strings.TrimSpace(os.Getenv(fmt.Sprintf("GF_DS_%s_%s", caseSensitiveDsUID, key))); v != "" && caseSensitiveDsUID != "" {
// output = v
// }
return output
}

0 comments on commit 90fbfb8

Please sign in to comment.