Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions server/channels/api4/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions server/channels/api4/post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions server/channels/app/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions server/channels/app/post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<Footer
{...baseProps}
isInEditMode={false}
/>,
);

expect(screen.getByText('Help')).toBeInTheDocument();
});

it('should not render HelpButton when in edit mode', () => {
renderWithContext(
<Footer
{...baseProps}
isInEditMode={true}
/>,
);

expect(screen.queryByText('Help')).not.toBeInTheDocument();
});

it('should render HelpButton as a button element with correct attributes', () => {
renderWithContext(
<Footer
{...baseProps}
isInEditMode={false}
/>,
);

const helpButton = screen.getByText('Help');
expect(helpButton.tagName).toBe('BUTTON');
expect(helpButton).toHaveAttribute('type', 'button');
expect(helpButton).toHaveAttribute('aria-label', 'Messaging help');
});
});

describe('Footer structure', () => {
it('should render the footer with correct id and role', () => {
const {container} = renderWithContext(
<Footer {...baseProps}/>,
);

const footer = container.querySelector('#postCreateFooter');
expect(footer).toBeInTheDocument();
expect(footer).toHaveAttribute('role', 'form');
});

it('should render MsgTyping when not in edit mode', () => {
renderWithContext(
<Footer
{...baseProps}
isInEditMode={false}
/>,
);

// MsgTyping component should be rendered (it renders an empty span by default)
const footer = screen.getByRole('form');
expect(footer).toBeInTheDocument();
});

it('should not render MsgTyping when in edit mode', () => {
const {container} = renderWithContext(
<Footer
{...baseProps}
isInEditMode={true}
/>,
);

// The MsgTyping component should not be in the DOM when in edit mode
// This is a basic structural check
const footer = container.querySelector('#postCreateFooter');
expect(footer).toBeInTheDocument();
});
});
});

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {Post} from '@mattermost/types/posts';
import MessageSubmitError from 'components/message_submit_error';
import MsgTyping from 'components/msg_typing';

import HelpButton from './help_button';

interface Props {
postError?: ReactNode;
errorClass: string | null;
Expand Down Expand Up @@ -55,6 +57,7 @@ export default function Footer({
rootId={rootId}
/>
)}
{!isInEditMode && <HelpButton/>}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

.HelpButton {
display: flex;
height: 20px;
justify-content: flex-end;

&__link {
padding: 0;
border: none;
background: none;
color: rgba(var(--center-channel-color-rgb), 0.75);
cursor: pointer;
font-size: 12px;
text-decoration: none;

&:hover {
color: var(--button-bg);
text-decoration: underline;
}

&:focus {
outline: none;
}

&:focus-visible {
border-radius: 2px;
outline: 2px solid var(--button-bg);
outline-offset: 2px;
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import React, {memo, useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';

import {popoutHelp, canPopout} from 'utils/popouts/popout_windows';

import './help_button.scss';

const HelpButton = (): JSX.Element => {
const intl = useIntl();

const handleClick = useCallback(() => {
if (canPopout()) {
popoutHelp();
} else {
window.open('/help', '_blank', 'noopener,noreferrer');
}
}, []);

return (
<div className='HelpButton'>
<button
type='button'
className='HelpButton__link'
onClick={handleClick}
aria-label={intl.formatMessage({id: 'advanced_text_editor.help_link.aria', defaultMessage: 'Messaging help'})}
>
<FormattedMessage
id='advanced_text_editor.help_link'
defaultMessage='Help'
/>
</button>
</div>
);
};

export default memo(HelpButton);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

export {default} from './help_button';

Loading