From f6d3a7827eee2bef4d420b5655606150dfda7b7a Mon Sep 17 00:00:00 2001 From: Maria A Nunez Date: Fri, 5 Jun 2026 03:59:53 -0400 Subject: [PATCH] [release-10.11] MM-69010: Validate incoming webhook user membership (#36917) Automatic Merge --- server/channels/api4/webhook.go | 18 ++++++- server/channels/api4/webhook_test.go | 70 ++++++++++++++++++++++++++++ server/channels/app/webhook.go | 34 ++++++++++++++ server/channels/app/webhook_test.go | 31 ++++++++++++ server/i18n/en.json | 8 ++++ 5 files changed, 160 insertions(+), 1 deletion(-) diff --git a/server/channels/api4/webhook.go b/server/channels/api4/webhook.go index eb40262535..eb6c910346 100644 --- a/server/channels/api4/webhook.go +++ b/server/channels/api4/webhook.go @@ -64,11 +64,18 @@ func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { return } - if _, err = c.App.GetUser(hook.UserId); err != nil { + var hookUser *model.User + if hookUser, err = c.App.GetUser(hook.UserId); err != nil { c.Err = err return } + if appErr := c.App.ValidateIncomingWebhookUser(c.AppContext, *c.AppContext.Session(), hookUser, channel); appErr != nil { + c.LogAudit("fail - invalid webhook user") + c.Err = appErr + return + } + userId = hook.UserId } @@ -162,6 +169,15 @@ func updateIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { } } + // Moving the hook must not attribute its owner's posts to a channel they cannot access. + if updatedHook.ChannelId != oldHook.ChannelId { + if appErr := c.App.ValidateIncomingWebhookUserChannelAccess(c.AppContext, oldHook.UserId, channel); appErr != nil { + c.LogAudit("fail - invalid webhook user") + c.Err = appErr + return + } + } + incomingHook, err := c.App.UpdateIncomingWebhook(oldHook, &updatedHook) if err != nil { c.Err = err diff --git a/server/channels/api4/webhook_test.go b/server/channels/api4/webhook_test.go index f98c037e26..a575f2858f 100644 --- a/server/channels/api4/webhook_test.go +++ b/server/channels/api4/webhook_test.go @@ -146,6 +146,76 @@ func TestCreateIncomingWebhook_BypassTeamPermissions(t *testing.T) { CheckForbiddenStatus(t, resp) } +func TestIncomingWebhookValidateUser(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer th.RestoreDefaultRolePermissions(defaultRolePermissions) + th.AddPermissionToRole(model.PermissionManageIncomingWebhooks.Id, model.TeamAdminRoleId) + + th.LoginTeamAdmin() + + t.Run("cannot assign a user who is not a member of the team or channel", func(t *testing.T) { + nonMember := th.CreateUser() + + hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: nonMember.Id} + _, resp, err := th.Client.CreateIncomingWebhook(context.Background(), hook) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("cannot assign a user with higher privileges than the requester", func(t *testing.T) { + th.LinkUserToTeam(th.SystemAdminUser, th.BasicTeam) + _, appErr := th.App.AddUserToChannel(th.Context, th.SystemAdminUser, th.BasicChannel, false) + require.Nil(t, appErr) + + hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: th.SystemAdminUser.Id} + _, resp, err := th.Client.CreateIncomingWebhook(context.Background(), hook) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("can assign a user who is a member of the channel", func(t *testing.T) { + hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id} + created, _, err := th.Client.CreateIncomingWebhook(context.Background(), hook) + require.NoError(t, err) + require.Equal(t, th.BasicUser2.Id, created.UserId) + }) + + t.Run("update cannot move another user's hook to a channel they cannot access", func(t *testing.T) { + hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id} + created, _, err := th.Client.CreateIncomingWebhook(context.Background(), hook) + require.NoError(t, err) + + privateChannel := th.CreatePrivateChannel() + created.ChannelId = privateChannel.Id + + _, resp, err := th.Client.UpdateIncomingWebhook(context.Background(), created) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("update validates the retained owner even when the payload also changes the owner", func(t *testing.T) { + hook := &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id} + created, _, err := th.Client.CreateIncomingWebhook(context.Background(), hook) + require.NoError(t, err) + + // The owner is immutable on update, so changing it alongside the channel must not + // let the supplied user stand in for the retained owner's channel access. + privateChannel := th.CreatePrivateChannel() + created.ChannelId = privateChannel.Id + created.UserId = th.TeamAdminUser.Id + + _, resp, err := th.Client.UpdateIncomingWebhook(context.Background(), created) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) +} + func TestGetIncomingWebhooks(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() diff --git a/server/channels/app/webhook.go b/server/channels/app/webhook.go index 70692e09f6..03ff9fddff 100644 --- a/server/channels/app/webhook.go +++ b/server/channels/app/webhook.go @@ -387,6 +387,29 @@ func (a *App) CreateWebhookPost(c request.CTX, userID string, channel *model.Cha return splits[0], nil } +// ValidateIncomingWebhookUser ensures a user being assigned as an incoming webhook's owner can +// legitimately be attributed posts in the target channel: the user must have access to the +// channel and must not hold privileges the requester lacks, so a requester cannot forge posts +// as a non-member or higher-privileged user. +func (a *App) ValidateIncomingWebhookUser(rctx request.CTX, session model.Session, user *model.User, channel *model.Channel) *model.AppError { + if user.IsSystemAdmin() && !a.SessionHasPermissionTo(session, model.PermissionManageSystem) { + return model.NewAppError("ValidateIncomingWebhookUser", "api.webhook.incoming.user_role.app_error", nil, "user_id="+user.Id, http.StatusForbidden) + } + + return a.ValidateIncomingWebhookUserChannelAccess(rctx, user.Id, channel) +} + +// ValidateIncomingWebhookUserChannelAccess ensures the webhook owner can read the channel its +// posts are attributed to, preventing attribution to a user who is not a member of the channel +// (or its team, for open channels). +func (a *App) ValidateIncomingWebhookUserChannelAccess(rctx request.CTX, userID string, channel *model.Channel) *model.AppError { + if hasPermission, _ := a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent); !hasPermission { + return model.NewAppError("ValidateIncomingWebhookUserChannelAccess", "api.webhook.incoming.user_membership.app_error", nil, "user_id="+userID+", channel_id="+channel.Id, http.StatusForbidden) + } + + return nil +} + func (a *App) CreateIncomingWebhookForChannel(creatorId string, channel *model.Channel, hook *model.IncomingWebhook) (*model.IncomingWebhook, *model.AppError) { if !*a.Config().ServiceSettings.EnableIncomingWebhooks { return nil, model.NewAppError("CreateIncomingWebhookForChannel", "api.incoming_webhook.disabled.app_error", nil, "", http.StatusNotImplemented) @@ -797,6 +820,17 @@ func (a *App) HandleIncomingWebhook(c request.CTX, hookID string, req *model.Inc if nErr != nil { return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.user.app_error", map[string]any{"user": channelName[1:]}, "", http.StatusBadRequest).Wrap(nErr) } + // Only allow a DM target the webhook owner shares a team with, so the stored + // user_id cannot be used to reach users the owner could not message directly. + if hook.UserId != result.Id { + commonTeamIDs, teamErr := a.GetCommonTeamIDsForTwoUsers(hook.UserId, result.Id) + if teamErr != nil { + return teamErr + } + if len(commonTeamIDs) == 0 { + return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.permissions.app_error", map[string]any{"user": hook.UserId, "channel": channelName}, "", http.StatusForbidden) + } + } ch, err := a.GetOrCreateDirectChannel(c, hook.UserId, result.Id) if err != nil { return err diff --git a/server/channels/app/webhook_test.go b/server/channels/app/webhook_test.go index 5f3f1b2e1b..e7ffa7a9a6 100644 --- a/server/channels/app/webhook_test.go +++ b/server/channels/app/webhook_test.go @@ -22,6 +22,37 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/testlib" ) +func TestHandleIncomingWebhookDirectMessage(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) + + hook, appErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, ChannelLocked: false}) + require.Nil(t, appErr) + defer func() { + require.Nil(t, th.App.DeleteIncomingWebhook(hook.Id)) + }() + + t.Run("rejects DM to a user the owner shares no team with", func(t *testing.T) { + stranger := th.CreateUser() + err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{ + Text: "out of team dm", + ChannelName: "@" + stranger.Username, + }) + require.NotNil(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) + + t.Run("allows DM to a user the owner shares a team with", func(t *testing.T) { + err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{ + Text: "team dm", + ChannelName: "@" + th.BasicUser2.Username, + }) + require.Nil(t, err) + }) +} + func TestCreateIncomingWebhookForChannel(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() diff --git a/server/i18n/en.json b/server/i18n/en.json index 28c5132f5c..a8e4dbe565 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -4554,6 +4554,14 @@ "id": "api.webhook.create_outgoing.triggers.app_error", "translation": "Either trigger_words or channel_id must be set." }, + { + "id": "api.webhook.incoming.user_membership.app_error", + "translation": "The webhook user must be a member of the target team or channel." + }, + { + "id": "api.webhook.incoming.user_role.app_error", + "translation": "You cannot assign a webhook to a user with higher privileges than your own." + }, { "id": "api.webhook.team_mismatch.app_error", "translation": "Unable to update webhook across teams."