MM-69010: Validate incoming webhook user membership (#36811) (#36885)

Automatic Merge
This commit is contained in:
Mattermost Build
2026-06-04 10:24:06 +02:00
committed by GitHub
parent c54329b5ff
commit 0c1e150c31
5 changed files with 158 additions and 1 deletions
+17 -1
View File
@@ -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
}
@@ -167,6 +174,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
}
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionBypassIncomingWebhookChannelLock) {
updatedHook.ChannelLocked = true
updatedHook.ChannelId = channel.Id
+69
View File
@@ -192,6 +192,75 @@ func TestCreateIncomingWebhook_BypassTeamPermissions(t *testing.T) {
CheckForbiddenStatus(t, resp)
}
func TestIncomingWebhookValidateUser(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true })
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
addIncomingWebhookPermissionsWithOthers(t, th, model.TeamAdminRoleId)
th.LoginTeamAdmin(t)
t.Run("cannot assign a user who is not a member of the team or channel", func(t *testing.T) {
nonMember := th.CreateUser(t)
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(t, 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(t)
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(t)
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(t)
+34
View File
@@ -387,6 +387,29 @@ func (a *App) CreateWebhookPost(rctx request.CTX, userID string, channel *model.
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(rctx request.CTX, hookID string, req *model.
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(rctx, hook.UserId, result.Id)
if err != nil {
return err
+30
View File
@@ -98,6 +98,36 @@ func TestHandleIncomingWebhookRootId(t *testing.T) {
})
}
func TestHandleIncomingWebhookDirectMessage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
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(t)
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(t)
+8
View File
@@ -5214,6 +5214,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."