diff --git a/app/integration_action.go b/app/integration_action.go index 5c95cf6377..628daba851 100644 --- a/app/integration_action.go +++ b/app/integration_action.go @@ -21,6 +21,8 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/gorilla/mux" + "io/ioutil" "net/http" "net/url" "path" @@ -242,19 +244,16 @@ func (a *App) DoPostActionWithCookie(postId, actionId, userId, selectedOption st // Perform an HTTP POST request to an integration's action endpoint. // Caller must consume and close returned http.Response as necessary. +// For internal requests, requests are routed directly to a plugin ServerHTTP hook func (a *App) DoActionRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { inURL, err := url.Parse(rawURL) if err != nil { return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, err.Error(), http.StatusBadRequest) } - siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL) rawURLPath := path.Clean(rawURL) - if siteURL != nil && (strings.HasPrefix(rawURLPath, "/plugins/") || strings.HasPrefix(rawURLPath, "plugins/")) { - inURL.Scheme = siteURL.Scheme - inURL.Host = siteURL.Host - inURL.Path = path.Join("/", siteURL.Path, rawURLPath) - rawURL = inURL.String() + if strings.HasPrefix(rawURLPath, "/plugins/") || strings.HasPrefix(rawURLPath, "plugins/") { + return a.DoLocalRequest(rawURLPath, body) } req, err := http.NewRequest("POST", rawURL, bytes.NewReader(body)) @@ -267,6 +266,7 @@ func (a *App) DoActionRequest(rawURL string, body []byte) (*http.Response, *mode // Allow access to plugin routes for action buttons var httpClient *http.Client subpath, _ := utils.GetSubpathFromConfig(a.Config()) + siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL) if (inURL.Hostname() == "localhost" || inURL.Hostname() == "127.0.0.1" || inURL.Hostname() == siteURL.Hostname()) && strings.HasPrefix(inURL.Path, path.Join(subpath, "plugins")) { req.Header.Set(model.HEADER_AUTH, "Bearer "+a.Session.Token) httpClient = a.HTTPService.MakeClient(true) @@ -286,6 +286,74 @@ func (a *App) DoActionRequest(rawURL string, body []byte) (*http.Response, *mode return resp, nil } +type LocalResponseWriter struct { + data []byte + headers http.Header + status int +} + +func (w *LocalResponseWriter) Header() http.Header { + if w.headers == nil { + w.headers = make(http.Header) + } + return w.headers +} + +func (w *LocalResponseWriter) Write(bytes []byte) (int, error) { + w.data = make([]byte, len(bytes)) + copy(w.data, bytes) + return len(w.data), nil +} + +func (w *LocalResponseWriter) WriteHeader(statusCode int) { + w.status = statusCode +} + +func (a *App) DoLocalRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { + rawURL = strings.TrimPrefix(rawURL, "/") + inURL, err := url.Parse(rawURL) + if err != nil { + return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + } + result := strings.Split(inURL.Path, "/") + if len(result) < 2 { + return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err=Unable to find pluginId", http.StatusBadRequest) + } + if result[0] != "plugins" { + return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err=plugins not in path", http.StatusBadRequest) + } + pluginId := result[1] + + path := strings.TrimPrefix(inURL.Path, "plugins/"+pluginId) + + w := &LocalResponseWriter{} + r, err := http.NewRequest("POST", path, bytes.NewReader(body)) + if err != nil { + return nil, model.NewAppError("DoActionRequest", "api.post.do_action.action_integration.app_error", nil, "err="+err.Error(), http.StatusBadRequest) + } + r.Header.Set("Mattermost-User-Id", a.Session.UserId) + r.Header.Set(model.HEADER_AUTH, "Bearer "+a.Session.Token) + params := make(map[string]string) + params["plugin_id"] = pluginId + r = mux.SetURLVars(r, params) + + a.ServePluginRequest(w, r) + + resp := &http.Response{ + StatusCode: w.status, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: w.headers, + Body: ioutil.NopCloser(bytes.NewReader(w.data)), + } + if resp.StatusCode == 0 { + resp.StatusCode = http.StatusOK + } + + return resp, nil +} + func (a *App) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError { clientTriggerId, userId, err := request.DecodeAndVerifyTriggerId(a.AsymmetricSigningKey()) if err != nil { diff --git a/app/integration_action_test.go b/app/integration_action_test.go index fb5727f7ae..420cca36ae 100644 --- a/app/integration_action_test.go +++ b/app/integration_action_test.go @@ -443,6 +443,37 @@ func TestSubmitInteractiveDialog(t *testing.T) { })) defer ts.Close() + setupPluginApiTest(t, + ` + package main + + import ( + "net/http" + "github.com/mattermost/mattermost-server/plugin" + "github.com/mattermost/mattermost-server/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { + response := &model.SubmitDialogResponse{ + Errors: map[string]string{"name1": "some error"}, + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(response.ToJson()) + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, `{"id": "myplugin", "backend": {"executable": "backend.exe"}}`, "myplugin", th.App) + + hooks, err2 := th.App.GetPluginsEnvironment().HooksForPlugin("myplugin") + require.Nil(t, err2) + require.NotNil(t, hooks) + submit.URL = ts.URL resp, err := th.App.SubmitInteractiveDialog(submit) @@ -601,7 +632,8 @@ func TestPostActionRelativeURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions[0].Id) _, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") - require.Nil(t, err) + require.NotNil(t, err) + }) t.Run("valid (but dirty) relative URL with SiteURL set", func(t *testing.T) { @@ -641,7 +673,7 @@ func TestPostActionRelativeURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions[0].Id) _, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") - require.Nil(t, err) + require.NotNil(t, err) }) t.Run("valid relative URL with SiteURL set and no leading slash", func(t *testing.T) { @@ -680,6 +712,200 @@ func TestPostActionRelativeURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) + _, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") + require.NotNil(t, err) + }) +} + +func TestPostActionRelativePluginURL(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + setupPluginApiTest(t, + ` + package main + + import ( + "net/http" + "github.com/mattermost/mattermost-server/plugin" + "github.com/mattermost/mattermost-server/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { + response := &model.PostActionIntegrationResponse{} + w.WriteHeader(http.StatusOK) + _, _ = w.Write(response.ToJson()) + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, `{"id": "myplugin", "backend": {"executable": "backend.exe"}}`, "myplugin", th.App) + + hooks, err2 := th.App.GetPluginsEnvironment().HooksForPlugin("myplugin") + require.Nil(t, err2) + require.NotNil(t, hooks) + + t.Run("invalid relative URL", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "" + *cfg.ServiceSettings.SiteURL = "" + }) + + interactivePost := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + URL: "/notaplugin/some/path", + }, + Name: "action", + Type: "some_type", + }, + }, + }, + }, + }, + } + + post, err := th.App.CreatePostAsUser(&interactivePost, "") + require.Nil(t, err) + attachments, ok := post.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + require.NotEmpty(t, attachments[0].Actions) + require.NotEmpty(t, attachments[0].Actions[0].Id) + + _, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") + require.NotNil(t, err) + }) + + t.Run("valid relative URL", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "" + *cfg.ServiceSettings.SiteURL = "" + }) + + interactivePost := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + URL: "/plugins/myplugin/myaction", + }, + Name: "action", + Type: "some_type", + }, + }, + }, + }, + }, + } + + post, err := th.App.CreatePostAsUser(&interactivePost, "") + require.Nil(t, err) + attachments, ok := post.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + require.NotEmpty(t, attachments[0].Actions) + require.NotEmpty(t, attachments[0].Actions[0].Id) + + _, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") + require.Nil(t, err) + }) + + t.Run("valid (but dirty) relative URL", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "" + *cfg.ServiceSettings.SiteURL = "" + }) + + interactivePost := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + URL: "//plugins/myplugin///myaction", + }, + Name: "action", + Type: "some_type", + }, + }, + }, + }, + }, + } + + post, err := th.App.CreatePostAsUser(&interactivePost, "") + require.Nil(t, err) + attachments, ok := post.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + require.NotEmpty(t, attachments[0].Actions) + require.NotEmpty(t, attachments[0].Actions[0].Id) + + _, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") + require.Nil(t, err) + }) + + t.Run("valid relative URL and no leading slash", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "" + *cfg.ServiceSettings.SiteURL = "" + }) + + interactivePost := model.Post{ + Message: "Interactive post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Integration: &model.PostActionIntegration{ + URL: "plugins/myplugin/myaction", + }, + Name: "action", + Type: "some_type", + }, + }, + }, + }, + }, + } + + post, err := th.App.CreatePostAsUser(&interactivePost, "") + require.Nil(t, err) + attachments, ok := post.Props["attachments"].([]*model.SlackAttachment) + require.True(t, ok) + require.NotEmpty(t, attachments[0].Actions) + require.NotEmpty(t, attachments[0].Actions[0].Id) + _, err = th.App.DoPostAction(post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "") require.Nil(t, err) })