mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Canvas: Allow API calls to grafana origin (#91822)
* allow post URL * check for config * allow relative paths * add allowed internal pattern; add checks for method * update defaults.ini * add custom header * update config comment * use globbing, switch to older middleware - deprecated call * add codeowner * update to use current api, add test * update fall through logic * Update pkg/middleware/validate_action_url.go Co-authored-by: Dan Cech <dcech@grafana.com> * Update pkg/middleware/validate_action_url.go Co-authored-by: Dan Cech <dcech@grafana.com> * add more tests * Update pkg/middleware/validate_action_url_test.go Co-authored-by: Dan Cech <dcech@grafana.com> * fix request headers * add additional tests for all verbs * fix request headers++ * throw error when method is unknown --------- Co-authored-by: Ryan McKinley <ryantxu@gmail.com> Co-authored-by: Brian Gann <bkgann@gmail.com> Co-authored-by: Brian Gann <briangann@users.noreply.github.com> Co-authored-by: Dan Cech <dcech@grafana.com>
This commit is contained in:
parent
462560d544
commit
f64b121ddb
@ -406,6 +406,9 @@ csrf_always_check = false
|
|||||||
# Comma-separated list of plugins ids that won't be loaded inside the frontend sandbox
|
# Comma-separated list of plugins ids that won't be loaded inside the frontend sandbox
|
||||||
disable_frontend_sandbox_for_plugins = grafana-incident-app
|
disable_frontend_sandbox_for_plugins = grafana-incident-app
|
||||||
|
|
||||||
|
# Comma-separated list of paths for POST/PUT URL in actions. Empty will allow anything that is not on the same origin
|
||||||
|
actions_allow_post_url =
|
||||||
|
|
||||||
[security.encryption]
|
[security.encryption]
|
||||||
# Defines the time-to-live (TTL) for decrypted data encryption keys stored in memory (cache).
|
# Defines the time-to-live (TTL) for decrypted data encryption keys stored in memory (cache).
|
||||||
# Please note that small values may cause performance issues due to a high frequency decryption operations.
|
# Please note that small values may cause performance issues due to a high frequency decryption operations.
|
||||||
|
@ -411,6 +411,9 @@
|
|||||||
# Comma-separated list of plugins ids that won't be loaded inside the frontend sandbox
|
# Comma-separated list of plugins ids that won't be loaded inside the frontend sandbox
|
||||||
;disable_frontend_sandbox_for_plugins =
|
;disable_frontend_sandbox_for_plugins =
|
||||||
|
|
||||||
|
# Comma-separated list of paths for POST/PUT URL in actions. Empty will allow anything that is not on the same origin
|
||||||
|
;actions_allow_post_url =
|
||||||
|
|
||||||
[security.encryption]
|
[security.encryption]
|
||||||
# Defines the time-to-live (TTL) for decrypted data encryption keys stored in memory (cache).
|
# Defines the time-to-live (TTL) for decrypted data encryption keys stored in memory (cache).
|
||||||
# Please note that small values may cause performance issues due to a high frequency decryption operations.
|
# Please note that small values may cause performance issues due to a high frequency decryption operations.
|
||||||
|
@ -641,6 +641,8 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
|
|||||||
if hs.Cfg.EnforceDomain {
|
if hs.Cfg.EnforceDomain {
|
||||||
m.Use(middleware.ValidateHostHeader(hs.Cfg))
|
m.Use(middleware.ValidateHostHeader(hs.Cfg))
|
||||||
}
|
}
|
||||||
|
// handle action urls
|
||||||
|
m.UseMiddleware(middleware.ValidateActionUrl(hs.Cfg, hs.log))
|
||||||
|
|
||||||
m.Use(middleware.HandleNoCacheHeaders)
|
m.Use(middleware.HandleNoCacheHeaders)
|
||||||
|
|
||||||
|
@ -244,6 +244,8 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func(
|
|||||||
ctxHdlr := getContextHandler(t, cfg, sc.authnService)
|
ctxHdlr := getContextHandler(t, cfg, sc.authnService)
|
||||||
sc.m.Use(ctxHdlr.Middleware)
|
sc.m.Use(ctxHdlr.Middleware)
|
||||||
sc.m.Use(OrgRedirect(sc.cfg, sc.userService))
|
sc.m.Use(OrgRedirect(sc.cfg, sc.userService))
|
||||||
|
// handle action urls
|
||||||
|
sc.m.Use(ValidateActionUrl(sc.cfg, logger))
|
||||||
|
|
||||||
sc.defaultHandler = func(c *contextmodel.ReqContext) {
|
sc.defaultHandler = func(c *contextmodel.ReqContext) {
|
||||||
require.NotNil(t, c)
|
require.NotNil(t, c)
|
||||||
|
119
pkg/middleware/validate_action_url.go
Normal file
119
pkg/middleware/validate_action_url.go
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gobwas/glob"
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/services/contexthandler"
|
||||||
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidAllowedURL = func(url string) error {
|
||||||
|
return fmt.Errorf("action URL '%s' is invalid", url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type errorWithStatus struct {
|
||||||
|
Underlying error
|
||||||
|
HTTPStatus int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e errorWithStatus) Error() string {
|
||||||
|
return e.Underlying.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e errorWithStatus) Unwrap() error {
|
||||||
|
return e.Underlying
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateActionUrl(cfg *setting.Cfg, logger log.Logger) func(http.Handler) http.Handler {
|
||||||
|
// get the urls allowed from server config
|
||||||
|
allGlobs, globErr := cacheGlobs(cfg.ActionsAllowPostURL)
|
||||||
|
if globErr != nil {
|
||||||
|
logger.Error("invalid glob settings in config section [security] actions_allow_post_url", "url", cfg.ActionsAllowPostURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := contexthandler.FromContext(r.Context())
|
||||||
|
// if no header
|
||||||
|
// return nil
|
||||||
|
// check if action header exists
|
||||||
|
action := ctx.Req.Header.Get("X-Grafana-Action")
|
||||||
|
if action == "" {
|
||||||
|
// header not found, this is not an action request
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if globErr != nil {
|
||||||
|
http.Error(w, "check server logs for glob configuration failure", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchErr := check(ctx, allGlobs, logger)
|
||||||
|
if matchErr != nil {
|
||||||
|
http.Error(w, matchErr.Error(), http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// no errors fall through
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check
|
||||||
|
// Detects header for action urls and compares to globbed pattern list
|
||||||
|
// returns true if allowed
|
||||||
|
func check(ctx *contextmodel.ReqContext, allGlobs *[]glob.Glob, logger log.Logger) *errorWithStatus {
|
||||||
|
// only process POST and PUT
|
||||||
|
if ctx.Req.Method != http.MethodPost && ctx.Req.Method != http.MethodPut {
|
||||||
|
return &errorWithStatus{
|
||||||
|
Underlying: fmt.Errorf("method not allowed for path %s", ctx.Req.URL),
|
||||||
|
HTTPStatus: http.StatusMethodNotAllowed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// for each split config
|
||||||
|
// if matches glob
|
||||||
|
// return nil
|
||||||
|
urlToCheck := ctx.Req.URL
|
||||||
|
if matchesAllowedPath(allGlobs, urlToCheck.Path) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger.Warn("POST/PUT to path not allowed", "url", urlToCheck)
|
||||||
|
// return some error
|
||||||
|
return &errorWithStatus{
|
||||||
|
Underlying: fmt.Errorf("method POST/PUT not allowed for path %s", urlToCheck),
|
||||||
|
HTTPStatus: http.StatusMethodNotAllowed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAllowedPath(allGlobs *[]glob.Glob, pathToCheck string) bool {
|
||||||
|
logger.Debug("Checking url", "actions", pathToCheck)
|
||||||
|
for _, rule := range *allGlobs {
|
||||||
|
logger.Debug("Checking match", "actions", rule)
|
||||||
|
if rule.Match(pathToCheck) {
|
||||||
|
// allowed
|
||||||
|
logger.Debug("POST/PUT call matches allow configuration settings")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheGlobs(actionsAllowPostURL string) (*[]glob.Glob, error) {
|
||||||
|
allowedUrls := util.SplitString(actionsAllowPostURL)
|
||||||
|
allGlobs := make([]glob.Glob, 0)
|
||||||
|
for _, i := range allowedUrls {
|
||||||
|
g, err := glob.Compile(i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errInvalidAllowedURL(err.Error())
|
||||||
|
}
|
||||||
|
allGlobs = append(allGlobs, g)
|
||||||
|
}
|
||||||
|
return &allGlobs, nil
|
||||||
|
}
|
306
pkg/middleware/validate_action_url_test.go
Normal file
306
pkg/middleware/validate_action_url_test.go
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMiddlewareValidateActionUrl(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
actionsAllowPostURL string
|
||||||
|
addHeader bool
|
||||||
|
code int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DELETE action with valid path",
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/api/plugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DELETE action with invalid path",
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/api/notplugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET action with valid path",
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/plugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET action with invalid path",
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/notplugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET valid path without header",
|
||||||
|
method: "GET",
|
||||||
|
path: "/", // top-level get
|
||||||
|
actionsAllowPostURL: "",
|
||||||
|
addHeader: false,
|
||||||
|
code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET valid path with header",
|
||||||
|
method: "GET",
|
||||||
|
path: "/", // top-level get
|
||||||
|
actionsAllowPostURL: "",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD request with header",
|
||||||
|
method: "HEAD",
|
||||||
|
path: "/", // top-level
|
||||||
|
actionsAllowPostURL: "",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OPTIONS request",
|
||||||
|
method: "OPTIONS",
|
||||||
|
path: "/", // top-level
|
||||||
|
actionsAllowPostURL: "",
|
||||||
|
addHeader: false,
|
||||||
|
code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OPTIONS request with header",
|
||||||
|
method: "OPTIONS",
|
||||||
|
path: "/", // top-level
|
||||||
|
actionsAllowPostURL: "",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PATCH request with header",
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/", // top-level
|
||||||
|
actionsAllowPostURL: "",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PATCH request without header",
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/", // top-level
|
||||||
|
actionsAllowPostURL: "",
|
||||||
|
addHeader: false,
|
||||||
|
code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "POST without action header",
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/plugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "",
|
||||||
|
addHeader: false,
|
||||||
|
code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "POST with action header, no paths defined",
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/plugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "POST action with allowed path",
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/plugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "POST action with invalid path",
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/notplugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PUT action with valid path with header",
|
||||||
|
method: "PUT",
|
||||||
|
path: "/api/plugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PUT action with invalid path",
|
||||||
|
method: "PUT",
|
||||||
|
path: "/api/notplugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PUT action with valid path without header",
|
||||||
|
method: "PUT",
|
||||||
|
path: "/api/plugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: false,
|
||||||
|
code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PUT action with invalid path without header",
|
||||||
|
method: "PUT",
|
||||||
|
path: "/api/notplugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: false,
|
||||||
|
code: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CONNECT unknown verb with header",
|
||||||
|
method: "CONNECT",
|
||||||
|
path: "/api/notplugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: true,
|
||||||
|
code: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CONNECT unknown verb without header",
|
||||||
|
method: "CONNECT",
|
||||||
|
path: "/api/notplugins/org-generic-app",
|
||||||
|
actionsAllowPostURL: "/api/plugins/*",
|
||||||
|
addHeader: false,
|
||||||
|
code: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
middlewareScenario(t, tt.name, func(t *testing.T, sc *scenarioContext) {
|
||||||
|
switch tt.method {
|
||||||
|
case "DELETE":
|
||||||
|
sc.m.Delete(tt.path, sc.defaultHandler)
|
||||||
|
case "GET":
|
||||||
|
sc.m.Get(tt.path, sc.defaultHandler)
|
||||||
|
case "HEAD":
|
||||||
|
sc.m.Head(tt.path, sc.defaultHandler)
|
||||||
|
case "OPTIONS":
|
||||||
|
sc.m.Options(tt.path, sc.defaultHandler)
|
||||||
|
case "PATCH":
|
||||||
|
sc.m.Patch(tt.path, sc.defaultHandler)
|
||||||
|
case "POST":
|
||||||
|
sc.m.Post(tt.path, sc.defaultHandler)
|
||||||
|
case "PUT":
|
||||||
|
sc.m.Put(tt.path, sc.defaultHandler)
|
||||||
|
default:
|
||||||
|
// anything else is an error
|
||||||
|
anError := fmt.Errorf("unknown verb: %s", tt.method)
|
||||||
|
if assert.Errorf(t, anError, "unknown verb: %s", tt.method) {
|
||||||
|
assert.Contains(t, anError.Error(), "unknown verb")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sc.fakeReq(tt.method, tt.path)
|
||||||
|
if tt.addHeader {
|
||||||
|
sc.req.Header.Add("X-Grafana-Action", "1")
|
||||||
|
}
|
||||||
|
sc.exec()
|
||||||
|
resp := sc.resp.Result()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := resp.Body.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
// nolint:bodyclose
|
||||||
|
assert.Equal(t, tt.code, sc.resp.Result().StatusCode)
|
||||||
|
}, func(cfg *setting.Cfg) {
|
||||||
|
cfg.ActionsAllowPostURL = tt.actionsAllowPostURL
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesAllowedPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
aPath string
|
||||||
|
allowList string
|
||||||
|
matches bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single url with match",
|
||||||
|
allowList: "/api/plugins/*",
|
||||||
|
aPath: "/api/plugins/my-plugin",
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single url no match",
|
||||||
|
allowList: "/api/plugins/*",
|
||||||
|
aPath: "/api/plugin/my-plugin",
|
||||||
|
matches: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple urls with match",
|
||||||
|
allowList: "/api/plugins/*, /api/other/**",
|
||||||
|
aPath: "/api/other/my-plugin",
|
||||||
|
matches: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple urls no match",
|
||||||
|
allowList: "/api/plugins/*, /api/other/**",
|
||||||
|
aPath: "/api/misc/my-plugin",
|
||||||
|
matches: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
allGlobs, err := cacheGlobs(tc.allowList)
|
||||||
|
matched := matchesAllowedPath(allGlobs, tc.aPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, matched, tc.matches)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheGlobs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
allowList string
|
||||||
|
expectedLength int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single url",
|
||||||
|
allowList: "/api/plugins",
|
||||||
|
expectedLength: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple urls",
|
||||||
|
allowList: "/api/plugins, /api/other/**",
|
||||||
|
expectedLength: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cache, err := cacheGlobs(tc.allowList)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, len(*cache), tc.expectedLength)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -180,6 +180,7 @@ type Cfg struct {
|
|||||||
DisableFrontendSandboxForPlugins []string
|
DisableFrontendSandboxForPlugins []string
|
||||||
DisableGravatar bool
|
DisableGravatar bool
|
||||||
DataProxyWhiteList map[string]bool
|
DataProxyWhiteList map[string]bool
|
||||||
|
ActionsAllowPostURL string
|
||||||
|
|
||||||
TempDataLifetime time.Duration
|
TempDataLifetime time.Duration
|
||||||
|
|
||||||
@ -1538,6 +1539,7 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error {
|
|||||||
|
|
||||||
cfg.ContentTypeProtectionHeader = security.Key("x_content_type_options").MustBool(true)
|
cfg.ContentTypeProtectionHeader = security.Key("x_content_type_options").MustBool(true)
|
||||||
cfg.XSSProtectionHeader = security.Key("x_xss_protection").MustBool(true)
|
cfg.XSSProtectionHeader = security.Key("x_xss_protection").MustBool(true)
|
||||||
|
cfg.ActionsAllowPostURL = security.Key("actions_allow_post_url").MustString("")
|
||||||
cfg.StrictTransportSecurity = security.Key("strict_transport_security").MustBool(false)
|
cfg.StrictTransportSecurity = security.Key("strict_transport_security").MustBool(false)
|
||||||
cfg.StrictTransportSecurityMaxAge = security.Key("strict_transport_security_max_age_seconds").MustInt(86400)
|
cfg.StrictTransportSecurityMaxAge = security.Key("strict_transport_security_max_age_seconds").MustInt(86400)
|
||||||
cfg.StrictTransportSecurityPreload = security.Key("strict_transport_security_preload").MustBool(false)
|
cfg.StrictTransportSecurityPreload = security.Key("strict_transport_security_preload").MustBool(false)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { AppEvents } from '@grafana/data';
|
import { AppEvents, textUtil } from '@grafana/data';
|
||||||
import { BackendSrvRequest, getBackendSrv, getTemplateSrv } from '@grafana/runtime';
|
import { BackendSrvRequest, getBackendSrv, getTemplateSrv } from '@grafana/runtime';
|
||||||
import { appEvents } from 'app/core/core';
|
import { appEvents } from 'app/core/core';
|
||||||
|
import { createAbsoluteUrl, RelativeUrl } from 'app/features/alerting/unified/utils/url';
|
||||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
|
|
||||||
import { HttpRequestMethod } from '../../panelcfg.gen';
|
import { HttpRequestMethod } from '../../panelcfg.gen';
|
||||||
@ -10,30 +11,26 @@ import { APIEditorConfig } from './APIEditor';
|
|||||||
type IsLoadingCallback = (loading: boolean) => void;
|
type IsLoadingCallback = (loading: boolean) => void;
|
||||||
|
|
||||||
export const callApi = (api: APIEditorConfig, updateLoadingStateCallback?: IsLoadingCallback) => {
|
export const callApi = (api: APIEditorConfig, updateLoadingStateCallback?: IsLoadingCallback) => {
|
||||||
if (api && api.endpoint) {
|
if (!api.endpoint) {
|
||||||
// If API endpoint origin matches Grafana origin, don't call it.
|
appEvents.emit(AppEvents.alertError, ['API endpoint is not defined.']);
|
||||||
const interpolatedUrl = interpolateVariables(api.endpoint);
|
return;
|
||||||
if (requestMatchesGrafanaOrigin(interpolatedUrl)) {
|
|
||||||
appEvents.emit(AppEvents.alertError, ['Cannot call API at Grafana origin.']);
|
|
||||||
updateLoadingStateCallback && updateLoadingStateCallback(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const request = getRequest(api);
|
|
||||||
|
|
||||||
getBackendSrv()
|
|
||||||
.fetch(request)
|
|
||||||
.subscribe({
|
|
||||||
error: (error) => {
|
|
||||||
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
|
|
||||||
console.error('API call error: ', error);
|
|
||||||
updateLoadingStateCallback && updateLoadingStateCallback(false);
|
|
||||||
},
|
|
||||||
complete: () => {
|
|
||||||
appEvents.emit(AppEvents.alertSuccess, ['API call was successful']);
|
|
||||||
updateLoadingStateCallback && updateLoadingStateCallback(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const request = getRequest(api);
|
||||||
|
|
||||||
|
getBackendSrv()
|
||||||
|
.fetch(request)
|
||||||
|
.subscribe({
|
||||||
|
error: (error) => {
|
||||||
|
appEvents.emit(AppEvents.alertError, ['An error has occurred. Check console output for more details.']);
|
||||||
|
console.error('API call error: ', error);
|
||||||
|
updateLoadingStateCallback && updateLoadingStateCallback(false);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
appEvents.emit(AppEvents.alertSuccess, ['API call was successful']);
|
||||||
|
updateLoadingStateCallback && updateLoadingStateCallback(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const interpolateVariables = (text: string) => {
|
export const interpolateVariables = (text: string) => {
|
||||||
@ -42,9 +39,10 @@ export const interpolateVariables = (text: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getRequest = (api: APIEditorConfig) => {
|
export const getRequest = (api: APIEditorConfig) => {
|
||||||
const requestHeaders: HeadersInit = [];
|
const endpoint = getEndpoint(interpolateVariables(api.endpoint));
|
||||||
|
const url = new URL(endpoint);
|
||||||
|
|
||||||
const url = new URL(interpolateVariables(api.endpoint!));
|
const requestHeaders: Record<string, string> = {};
|
||||||
|
|
||||||
let request: BackendSrvRequest = {
|
let request: BackendSrvRequest = {
|
||||||
url: url.toString(),
|
url: url.toString(),
|
||||||
@ -54,23 +52,24 @@ export const getRequest = (api: APIEditorConfig) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (api.headerParams) {
|
if (api.headerParams) {
|
||||||
api.headerParams.forEach((param) => {
|
api.headerParams.forEach(([name, value]) => {
|
||||||
requestHeaders.push([interpolateVariables(param[0]), interpolateVariables(param[1])]);
|
requestHeaders[interpolateVariables(name)] = interpolateVariables(value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (api.queryParams) {
|
if (api.queryParams) {
|
||||||
api.queryParams?.forEach((param) => {
|
api.queryParams?.forEach(([name, value]) => {
|
||||||
url.searchParams.append(interpolateVariables(param[0]), interpolateVariables(param[1]));
|
url.searchParams.append(interpolateVariables(name), interpolateVariables(value));
|
||||||
});
|
});
|
||||||
|
|
||||||
request.url = url.toString();
|
request.url = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (api.method === HttpRequestMethod.POST) {
|
if (api.method === HttpRequestMethod.POST) {
|
||||||
requestHeaders.push(['Content-Type', api.contentType!]);
|
requestHeaders['Content-Type'] = api.contentType!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestHeaders['X-Grafana-Action'] = '1';
|
||||||
request.headers = requestHeaders;
|
request.headers = requestHeaders;
|
||||||
|
|
||||||
return request;
|
return request;
|
||||||
@ -85,8 +84,13 @@ const getData = (api: APIEditorConfig) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestMatchesGrafanaOrigin = (requestEndpoint: string) => {
|
const getEndpoint = (endpoint: string) => {
|
||||||
const requestURL = new URL(requestEndpoint);
|
const isRelativeUrl = endpoint.startsWith('/');
|
||||||
const grafanaURL = new URL(window.location.origin);
|
if (isRelativeUrl) {
|
||||||
return requestURL.origin === grafanaURL.origin;
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const sanitizedRelativeURL = textUtil.sanitizeUrl(endpoint) as RelativeUrl;
|
||||||
|
endpoint = createAbsoluteUrl(sanitizedRelativeURL, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint;
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user