MM-70240: Adjust post and thread payload sanitization (#37920)

* MM-70216: Adjust post and thread payload sanitization

* MM-70216: Preserve MM blocks actions in thread test fixture

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Edgar Bellot Micó
2026-08-12 21:07:08 +02:00
committed by GitHub
parent bf0f9de078
commit d0be8f408e
6 changed files with 213 additions and 13 deletions
+1 -2
View File
@@ -3342,8 +3342,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(rctx request.CTX, postID
if mErr != nil {
return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(mErr)
}
a.sanitizeProfiles(thread.Participants, false)
thread.Post.SanitizeProps()
a.sanitizeThreadResponse(thread)
if a.IsCRTEnabledForUser(rctx, userID) {
payload, jsonErr := json.Marshal(thread)
+2 -4
View File
@@ -858,8 +858,7 @@ func (a *App) SendNotifications(rctx request.CTX, post *model.Post, team *model.
userThread.UnreadMentions = 0
userThread.UnreadReplies = 0
}
a.sanitizeProfiles(userThread.Participants, false)
userThread.Post.SanitizeProps()
a.sanitizeThreadResponse(userThread)
sanitizedPost, isMemberForPreview, err := a.SanitizePostMetadataForUser(rctx, userThread.Post, uid)
if err != nil {
@@ -1026,8 +1025,7 @@ func (a *App) RemoveNotifications(rctx request.CTX, post *model.Post, channel *m
previousUnreadMentions := int64(0)
previousUnreadReplies := int64(0)
a.sanitizeProfiles(userThread.Participants, false)
userThread.Post.SanitizeProps()
a.sanitizeThreadResponse(userThread)
sanitizedPost, isMemberForPreview, err1 := a.SanitizePostMetadataForUser(rctx, userThread.Post, userID)
if err1 != nil {
+6 -1
View File
@@ -3370,8 +3370,13 @@ func (a *App) CleanUpAfterPostDeletion(rctx request.CTX, post *model.Post, delet
return model.NewAppError("DeletePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
sanitizedPostJSON, jsonErr := post.ToJSON()
if jsonErr != nil {
return model.NewAppError("DeletePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
userMessage := model.NewWebSocketEvent(model.WebsocketEventPostDeleted, "", post.ChannelId, "", nil, "")
userMessage.Add("post", string(postJSON))
userMessage.Add("post", sanitizedPostJSON)
userMessage.GetBroadcast().ContainsSanitizedData = true
a.Publish(userMessage)
+104
View File
@@ -5450,6 +5450,110 @@ func TestPermanentDeletePost(t *testing.T) {
})
}
func TestCleanUpAfterPostDeletion(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
appErr := th.App.JoinChannel(th.Context, th.BasicChannel, th.BasicUser.Id)
require.Nil(t, appErr)
appErr = th.App.JoinChannel(th.Context, th.BasicChannel, th.BasicUser2.Id)
require.Nil(t, appErr)
waitForPostDeleted := func(t *testing.T, messages chan *model.WebSocketEvent) *model.WebSocketEvent {
t.Helper()
select {
case received := <-messages:
return received
case <-time.After(10 * time.Second):
require.Fail(t, "Did not receive websocket message in time")
return nil
}
}
t.Run("post_deleted broadcast to channel members strips action integrations", func(t *testing.T) {
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "interactive message",
Props: model.StringInterface{
model.PostPropsAttachments: []*model.MessageAttachment{
{
Text: "hello",
Actions: []*model.PostAction{
{
Type: model.PostActionTypeButton,
Name: "action",
Integration: &model.PostActionIntegration{
URL: "http://localhost:8065/secret-endpoint",
Context: map[string]any{"secret_marker": "s3cr3t"},
},
},
},
},
},
},
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
memberMessages, closeMemberWS := connectFakeWebSocket(t, th, th.BasicUser2.Id, "", []model.WebsocketEventType{model.WebsocketEventPostDeleted})
defer closeMemberWS()
_, appErr = th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id)
require.Nil(t, appErr)
memberEvent := waitForPostDeleted(t, memberMessages)
memberPostJSON, ok := memberEvent.GetData()["post"].(string)
require.True(t, ok)
assert.NotContains(t, memberPostJSON, "secret-endpoint")
assert.NotContains(t, memberPostJSON, "secret_marker")
assert.Nil(t, memberEvent.GetData()["delete_by"])
var memberPost model.Post
require.NoError(t, json.Unmarshal([]byte(memberPostJSON), &memberPost))
require.Equal(t, post.Id, memberPost.Id)
memberAttachments := memberPost.Attachments()
require.Len(t, memberAttachments, 1)
require.Len(t, memberAttachments[0].Actions, 1)
assert.Equal(t, "action", memberAttachments[0].Actions[0].Name, "non-secret attachment data must be preserved")
assert.Nil(t, memberAttachments[0].Actions[0].Integration)
})
t.Run("post_deleted broadcast to channel members strips mm_blocks_actions secrets", func(t *testing.T) {
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "blocks message",
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
post.AddProp(model.PostPropsMmBlocksActions, map[string]any{
"mm_blocks_act": map[string]any{
"type": model.MmBlocksActionTypeExternal,
"url": "http://localhost:8065/secret-endpoint",
"context": map[string]any{"secret_marker": "s3cr3t"},
},
})
memberMessages, closeMemberWS := connectFakeWebSocket(t, th, th.BasicUser2.Id, "", []model.WebsocketEventType{model.WebsocketEventPostDeleted})
defer closeMemberWS()
appErr = th.App.CleanUpAfterPostDeletion(th.Context, post, th.BasicUser.Id)
require.Nil(t, appErr)
memberEvent := waitForPostDeleted(t, memberMessages)
memberPostJSON, ok := memberEvent.GetData()["post"].(string)
require.True(t, ok)
assert.NotContains(t, memberPostJSON, "secret-endpoint")
assert.NotContains(t, memberPostJSON, "secret_marker")
var memberPost model.Post
require.NoError(t, json.Unmarshal([]byte(memberPostJSON), &memberPost))
assert.Equal(t, post.Id, memberPost.Id)
assert.Nil(t, memberPost.GetProp(model.PostPropsMmBlocksActions))
})
}
func TestSendTestMessage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
+12 -6
View File
@@ -3062,8 +3062,7 @@ func (a *App) GetThreadsForUser(rctx request.CTX, userID, teamID string, options
Posts: make(map[string]*model.Post, len(result.Threads)),
}
for _, thread := range result.Threads {
a.sanitizeProfiles(thread.Participants, false)
thread.Post.SanitizeProps()
a.sanitizeThreadResponse(thread)
list.AddPost(thread.Post)
}
@@ -3098,12 +3097,20 @@ func (a *App) GetThreadForUser(rctx request.CTX, threadMembership *model.ThreadM
}
}
a.sanitizeProfiles(thread.Participants, false)
thread.Post.SanitizeProps()
a.sanitizeThreadResponse(thread)
a.populatePostListTranslations(rctx, &model.PostList{Posts: map[string]*model.Post{thread.Post.Id: thread.Post}})
return thread, nil
}
// sanitizeThreadResponse removes server-only data from a thread response before it is sent to clients.
func (a *App) sanitizeThreadResponse(thread *model.ThreadResponse) {
a.sanitizeProfiles(thread.Participants, false)
if thread.Post != nil {
thread.Post.SanitizeProps()
thread.Post.StripActionIntegrations()
}
}
func (a *App) UpdateThreadsReadForUser(userID, teamID string) *model.AppError {
nErr := a.Srv().Store().Thread().MarkAllAsReadByTeam(userID, teamID)
if nErr != nil {
@@ -3182,8 +3189,7 @@ func (a *App) UpdateThreadFollowForUserFromChannelAdd(rctx request.CTX, userID,
}
return model.NewAppError("UpdateThreadFollowForUserFromChannelAdd", "app.user.update_thread_follow_for_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.sanitizeProfiles(userThread.Participants, false)
userThread.Post.SanitizeProps()
a.sanitizeThreadResponse(userThread)
sanitizedPost, isMemberForPreviews, appErr := a.SanitizePostMetadataForUser(rctx, userThread.Post, userID)
if appErr != nil {
return appErr
+88
View File
@@ -2536,6 +2536,94 @@ func TestUpdateThreadReadForUser(t *testing.T) {
})
}
func TestGetThreadsForUserSanitizesRootPost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
appErr := th.App.JoinChannel(th.Context, th.BasicChannel, th.BasicUser.Id)
require.Nil(t, appErr)
appErr = th.App.JoinChannel(th.Context, th.BasicChannel, th.BasicUser2.Id)
require.Nil(t, appErr)
assertSanitized := func(t *testing.T, post *model.Post) {
t.Helper()
require.NotNil(t, post)
attachments := post.Attachments()
require.Len(t, attachments, 1)
require.Len(t, attachments[0].Actions, 1)
assert.Equal(t, "action", attachments[0].Actions[0].Name, "non-secret attachment data must be preserved")
assert.Nil(t, attachments[0].Actions[0].Integration)
assert.Nil(t, post.GetProp(model.PostPropsMmBlocksActions))
postJSON, err := json.Marshal(post)
require.NoError(t, err)
assert.NotContains(t, string(postJSON), "secret-endpoint")
assert.NotContains(t, string(postJSON), "secret_marker")
}
rootPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "interactive root",
Props: model.StringInterface{
model.PostPropsAttachments: []*model.MessageAttachment{
{
Text: "hello",
Actions: []*model.PostAction{
{
Type: model.PostActionTypeButton,
Name: "action",
Integration: &model.PostActionIntegration{
URL: "http://localhost:8065/secret-endpoint",
Context: map[string]any{"secret_marker": "s3cr3t"},
},
},
},
},
},
model.PostPropsMmBlocksActions: map[string]any{
"mm_blocks_act": map[string]any{
"type": model.MmBlocksActionTypeExternal,
"url": "http://localhost:8065/secret-endpoint",
"context": map[string]any{"secret_marker": "s3cr3t"},
},
},
},
}, th.BasicChannel, model.CreatePostFlags{AllowMmBlocksActions: true})
require.Nil(t, appErr)
require.NotNil(t, rootPost.GetProp(model.PostPropsMmBlocksActions))
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser2.Id,
ChannelId: th.BasicChannel.Id,
RootId: rootPost.Id,
Message: "reply",
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
t.Run("GetThreadForUser strips action integrations from the root post", func(t *testing.T) {
threadMembership, appErr := th.App.GetThreadMembershipForUser(th.BasicUser2.Id, rootPost.Id)
require.Nil(t, appErr)
thread, appErr := th.App.GetThreadForUser(th.Context, threadMembership, false)
require.Nil(t, appErr)
assertSanitized(t, thread.Post)
})
t.Run("GetThreadsForUser strips action integrations from the root post", func(t *testing.T) {
threads, appErr := th.App.GetThreadsForUser(th.Context, th.BasicUser2.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{PageSize: 10})
require.Nil(t, appErr)
require.Len(t, threads.Threads, 1)
assertSanitized(t, threads.Threads[0].Post)
})
}
func TestCreateUserWithInitialPreferences(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)