diff --git a/server/channels/api4/post.go b/server/channels/api4/post.go index dcc8129aeee..ee38135d16a 100644 --- a/server/channels/api4/post.go +++ b/server/channels/api4/post.go @@ -1010,6 +1010,10 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) { return } + // MM-67055: Strip client-supplied metadata.embeds to prevent spoofing. + // This matches createPost behavior. + post.SanitizeInput() + auditRec := c.MakeAuditRecord(model.AuditEventUpdatePost, model.AuditStatusFail) model.AddEventParameterAuditableToAuditRec(auditRec, "post", &post) defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index 23b65090ccf..67a6f69d4d9 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -1552,6 +1552,53 @@ func TestUpdatePost(t *testing.T) { assert.NotEqual(t, rpost3.Attachments(), rrupost3.Attachments()) }) + t.Run("should strip spoofed metadata embeds", func(t *testing.T) { + // MM-67055: Verify that client-supplied metadata.embeds are stripped + post := &model.Post{ + ChannelId: channel.Id, + Message: "test message " + model.NewId(), + } + createdPost, _, err := client.CreatePost(context.Background(), post) + require.NoError(t, err) + + // Try to update with spoofed embed + updatePost := &model.Post{ + Id: createdPost.Id, + ChannelId: channel.Id, + Message: "updated message " + model.NewId(), + Metadata: &model.PostMetadata{ + Embeds: []*model.PostEmbed{ + { + Type: model.PostEmbedPermalink, + Data: &model.PreviewPost{ + PostID: "spoofed-post-id", + Post: &model.Post{ + Id: "spoofed-post-id", + UserId: th.BasicUser2.Id, + Message: "This is a spoofed message!", + }, + }, + }, + }, + }, + } + + updatedPost, _, err := client.UpdatePost(context.Background(), createdPost.Id, updatePost) + require.NoError(t, err) + + // Verify spoofed embed was stripped + if updatedPost.Metadata != nil { + assert.Empty(t, updatedPost.Metadata.Embeds, "spoofed embeds should be stripped") + } + + // Double-check by fetching the post + fetchedPost, _, err := client.GetPost(context.Background(), createdPost.Id, "") + require.NoError(t, err) + if fetchedPost.Metadata != nil { + assert.Empty(t, fetchedPost.Metadata.Embeds, "spoofed embeds should not be persisted") + } + }) + t.Run("change message, but post too old", func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.PostEditTimeLimit = 1 diff --git a/server/channels/app/post.go b/server/channels/app/post.go index cb5276d11f4..3704995fbb3 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -849,6 +849,8 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda // Always use incoming metadata when provided, otherwise retain existing if receivedUpdatedPost.Metadata != nil { newPost.Metadata = receivedUpdatedPost.Metadata.Copy() + // MM-67055: Strip embeds - always server-generated. Preserves Priority/Acks for Shared Channels sync. + newPost.Metadata.Embeds = nil } else { // Restore the post metadata that was stripped by the plugin. Set it to // the last known good. diff --git a/server/channels/app/post_test.go b/server/channels/app/post_test.go index 493ccf179c3..2caf8c7a7b3 100644 --- a/server/channels/app/post_test.go +++ b/server/channels/app/post_test.go @@ -1952,6 +1952,60 @@ func TestUpdatePost(t *testing.T) { } }) + t.Run("should strip client-supplied embeds", func(t *testing.T) { + // MM-67055: Verify that client-supplied metadata.embeds are stripped. + // This prevents WebSocket message spoofing via permalink embeds. + // + // Note: Priority and Acknowledgements are stored in separate database tables, + // not in post metadata. Shared Channels handles them separately via + // syncRemotePriorityMetadata and syncRemoteAcknowledgementsMetadata after + // calling UpdatePost. See sync_recv.go::upsertSyncPost + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.AddUserToChannel(t, th.BasicUser, th.BasicChannel) + th.Context.Session().UserId = th.BasicUser.Id + + // Create a basic post + post := &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "original message", + UserId: th.BasicUser.Id, + } + createdPost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + require.Nil(t, err) + + // Try to update with spoofed embeds (the attack vector) + updatePost := &model.Post{ + Id: createdPost.Id, + ChannelId: th.BasicChannel.Id, + Message: "updated message", + UserId: th.BasicUser.Id, + Metadata: &model.PostMetadata{ + Embeds: []*model.PostEmbed{ + { + Type: model.PostEmbedPermalink, + Data: &model.PreviewPost{ + PostID: "spoofed-post-id", + Post: &model.Post{ + Id: "spoofed-post-id", + UserId: th.BasicUser2.Id, + Message: "Spoofed message from another user!", + }, + }, + }, + }, + }, + } + + updatedPost, err := th.App.UpdatePost(th.Context, updatePost, nil) + require.Nil(t, err) + require.NotNil(t, updatedPost.Metadata) + + // Verify embeds were stripped + assert.Empty(t, updatedPost.Metadata.Embeds, "spoofed embeds should be stripped") + }) + t.Run("cannot update post in restricted DM", func(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) diff --git a/server/go.mod b/server/go.mod index 3761f8deb5f..4a2097bf04e 100644 --- a/server/go.mod +++ b/server/go.mod @@ -177,8 +177,8 @@ require ( github.com/prometheus/procfs v0.17.0 // indirect github.com/redis/go-redis/v9 v9.14.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/richardlehane/mscfb v1.0.4 // indirect - github.com/richardlehane/msoleps v1.0.4 // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.5 // indirect github.com/rs/xid v1.6.0 // indirect github.com/russellhaering/goxmldsig v1.5.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/server/go.sum b/server/go.sum index 1719faab9df..02c392f14ad 100644 --- a/server/go.sum +++ b/server/go.sum @@ -540,9 +540,13 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.5 h1:kNlmACZuwC8ZWPLoJtD+HtZOsKJgYn7gXgUIcRB7dbo= +github.com/richardlehane/msoleps v1.0.5/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss index 9be8e654bdd..b41d26a8986 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss @@ -175,7 +175,8 @@ &__footer { position: relative; display: flex; - flex-direction: column; + flex-direction: row; + justify-content: space-between; padding: 0 24px; font-size: 12px; diff --git a/webapp/channels/src/components/advanced_text_editor/footer.test.tsx b/webapp/channels/src/components/advanced_text_editor/footer.test.tsx new file mode 100644 index 00000000000..7f37f9c4612 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/footer.test.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; + +import Footer from './footer'; + +describe('Footer Component', () => { + const baseProps = { + postError: null, + errorClass: null, + serverError: null, + channelId: 'channel_id', + rootId: '', + noArgumentHandleSubmit: jest.fn(), + isInEditMode: false, + }; + + describe('HelpButton visibility', () => { + it('should render HelpButton when not in edit mode', () => { + renderWithContext( +