[release-10.11] MM-69010: Validate incoming webhook user membership (#36917)

Automatic Merge
Этот коммит содержится в:
Maria A Nunez
2026-06-05 03:59:53 -04:00
коммит произвёл GitHub
родитель beaa59db54
Коммит f6d3a7827e
5 изменённых файлов: 160 добавлений и 1 удалений

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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()

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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()

Просмотреть файл

@@ -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."