From 9efe617be8b8f1d036e12721e8e73b69a543ed34 Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Fri, 23 Jan 2026 16:11:16 -0500 Subject: [PATCH 1/3] MM-67055: Fix permalink embeds in WebSocket messages (#34893) --- server/channels/api4/post.go | 4 +++ server/channels/api4/post_test.go | 47 +++++++++++++++++++++++++++ server/channels/app/post.go | 2 ++ server/channels/app/post_test.go | 54 +++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+) 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) From 37ec26b81a0d13c264ffd6afd6370bc83115307d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Fri, 23 Jan 2026 16:28:39 -0500 Subject: [PATCH 2/3] MM-61383 - add back offline user help for messagging (#34756) * MM-61383 - add back offline user help for messagging * Apply suggestions from code review Co-authored-by: Matthew Birtch * Apply suggestions from code review Co-authored-by: Matthew Birtch * Use non-breaking spaces for code indendation display * Fix avatar status badge being cut off in Mentioning Help page * show help in popout, if available * fix styling * missing i18n * help page titles * missing i18n * additional i18n * fix missing values * Make help link always visible, fix accessibility * Fix CSS property ordering in help button styles --------- Co-authored-by: Jesse Hallam Co-authored-by: Matthew Birtch Co-authored-by: Jesse Hallam Co-authored-by: Mattermost Build --- .../advanced_text_editor.scss | 3 +- .../advanced_text_editor/footer.test.tsx | 98 +++ .../advanced_text_editor/footer.tsx | 3 + .../help_button/help_button.scss | 34 + .../help_button/help_button.tsx | 39 + .../advanced_text_editor/help_button/index.ts | 5 + .../src/components/help/attaching.tsx | 214 +++++ .../src/components/help/avatar.svg.tsx | 72 ++ .../channels/src/components/help/commands.tsx | 194 +++++ .../src/components/help/formatting.tsx | 763 ++++++++++++++++++ webapp/channels/src/components/help/help.scss | 408 ++++++++++ webapp/channels/src/components/help/help.tsx | 45 ++ .../src/components/help/help_links.tsx | 92 +++ webapp/channels/src/components/help/index.ts | 4 + .../src/components/help/mentioning.tsx | 166 ++++ .../src/components/help/messaging.tsx | 195 +++++ .../channels/src/components/help/sending.tsx | 155 ++++ .../components/help/use_help_page_title.ts | 24 + .../components/help_popout/help_popout.scss | 7 + .../components/help_popout/help_popout.tsx | 16 + .../src/components/help_popout/index.ts | 4 + .../popout_controller/popout_controller.tsx | 5 + webapp/channels/src/components/root/root.tsx | 5 + webapp/channels/src/i18n/en.json | 133 +++ .../src/utils/popouts/popout_windows.ts | 15 + 25 files changed, 2698 insertions(+), 1 deletion(-) create mode 100644 webapp/channels/src/components/advanced_text_editor/footer.test.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/help_button/help_button.scss create mode 100644 webapp/channels/src/components/advanced_text_editor/help_button/help_button.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/help_button/index.ts create mode 100644 webapp/channels/src/components/help/attaching.tsx create mode 100644 webapp/channels/src/components/help/avatar.svg.tsx create mode 100644 webapp/channels/src/components/help/commands.tsx create mode 100644 webapp/channels/src/components/help/formatting.tsx create mode 100644 webapp/channels/src/components/help/help.scss create mode 100644 webapp/channels/src/components/help/help.tsx create mode 100644 webapp/channels/src/components/help/help_links.tsx create mode 100644 webapp/channels/src/components/help/index.ts create mode 100644 webapp/channels/src/components/help/mentioning.tsx create mode 100644 webapp/channels/src/components/help/messaging.tsx create mode 100644 webapp/channels/src/components/help/sending.tsx create mode 100644 webapp/channels/src/components/help/use_help_page_title.ts create mode 100644 webapp/channels/src/components/help_popout/help_popout.scss create mode 100644 webapp/channels/src/components/help_popout/help_popout.tsx create mode 100644 webapp/channels/src/components/help_popout/index.ts 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( +