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
5 changes: 5 additions & 0 deletions server/channels/api4/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ func createPostChecks(where string, c *Context, post *model.Post) {
}

postPriorityCheckWithContext(where, c, post.GetPriority(), post.RootId)
if c.Err != nil {
return
}

postBurnOnReadCheckWithContext(where, c, post, nil)
}

func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
Expand Down
8 changes: 8 additions & 0 deletions server/channels/api4/post_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ func postPriorityCheckWithContext(where string, c *Context, priority *model.Post
}
}

func postBurnOnReadCheckWithContext(where string, c *Context, post *model.Post, channel *model.Channel) {
appErr := app.PostBurnOnReadCheckWithApp(where, c.App, c.AppContext, post.UserId, post.ChannelId, post.Type, channel)
if appErr != nil {
appErr.Where = where
c.Err = appErr
}
}

// checkUploadFilePermissionForNewFiles checks upload_file permission only when
// adding new files to a post, preventing permission bypass via cross-channel file attachments.
func checkUploadFilePermissionForNewFiles(c *Context, newFileIds []string, originalPost *model.Post) {
Expand Down
11 changes: 11 additions & 0 deletions server/channels/api4/scheduled_post.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ func scheduledPostChecks(where string, c *Context, scheduledPost *model.Schedule
}

postPriorityCheckWithContext(where, c, scheduledPost.GetPriority(), scheduledPost.RootId)
if c.Err != nil {
return
}

// Validate burn-on-read restrictions for scheduled post
post := &model.Post{
ChannelId: scheduledPost.ChannelId,
UserId: scheduledPost.UserId,
Type: scheduledPost.Type,
}
postBurnOnReadCheckWithContext(where, c, post, nil)
}

func requireScheduledPostsEnabled(c *Context) {
Expand Down
6 changes: 6 additions & 0 deletions server/channels/app/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan
return nil, false, model.NewAppError("CreatePost", "app.post.create_post.shared_dm_or_gm.app_error", nil, "", http.StatusBadRequest)
}

// Validate burn-on-read restrictions (self-DMs, DMs with bots)
err = PostBurnOnReadCheckWithApp("App.CreatePost", a, rctx, post.UserId, post.ChannelId, post.Type, channel)
if err != nil {
return nil, false, err
}

foundPost, err := a.deduplicateCreatePost(rctx, post)
if err != nil {
return nil, false, err
Expand Down
44 changes: 44 additions & 0 deletions server/channels/app/post_permission_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,47 @@ func userCreatePostPermissionCheckWithApp(rctx request.CTX, a *App, userId, chan

return nil
}

// PostBurnOnReadCheckWithApp validates whether a burn-on-read post can be created
// based on channel type and participants. This is called from the API layer before
// post creation to enforce burn-on-read restrictions.
func PostBurnOnReadCheckWithApp(where string, a *App, rctx request.CTX, userId, channelId, postType string, channel *model.Channel) *model.AppError {
// Only validate if this is a burn-on-read post
if postType != model.PostTypeBurnOnRead {
return nil
}

// Get channel if not provided
if channel == nil {
ch, err := a.GetChannel(rctx, channelId)
if err != nil {
return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
channel = ch
}

// Burn-on-read is not allowed in self-DMs or DMs with bots (including AI agents, plugins)
if channel.Type == model.ChannelTypeDirect {
// Check if it's a self-DM by comparing the channel name with the expected self-DM name
selfDMName := model.GetDMNameFromIds(userId, userId)
if channel.Name == selfDMName {
return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.self_dm.app_error", nil, "", http.StatusBadRequest)
}

// Check if the DM is with a bot (AI agents, plugins, etc.)
otherUserId := channel.GetOtherUserIdForDM(userId)
if otherUserId != "" && otherUserId != userId {
otherUser, err := a.GetUser(otherUserId)
if err != nil {
// Failed to retrieve the other user (user not found, DB error, etc.)
// Block burn-on-read post as we cannot validate the recipient
return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if otherUser.IsBot {
return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error", nil, "", http.StatusBadRequest)
}
}
}

return nil
}
204 changes: 204 additions & 0 deletions server/channels/app/post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5368,6 +5368,210 @@ func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) {
})
}

func TestBurnOnReadRestrictionsForDMsAndBots(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
defer func() {
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
}()

th := Setup(t).InitBasic(t)

th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))

th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.EnableBurnOnRead = model.NewPointer(true)
cfg.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds = model.NewPointer(600)
cfg.ServiceSettings.BurnOnReadDurationSeconds = model.NewPointer(600)
})

t.Run("should allow burn-on-read posts in direct messages with another user", func(t *testing.T) {
// Create a direct message channel between two different users
dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
require.Equal(t, model.ChannelTypeDirect, dmChannel.Type)

post := &model.Post{
ChannelId: dmChannel.Id,
Message: "This is a burn-on-read message in DM",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}

createdPost, _, err := th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})

t.Run("should allow burn-on-read posts in group messages", func(t *testing.T) {
// Create a group message channel with at least 3 users
user3 := th.CreateUser(t)
th.LinkUserToTeam(t, user3, th.BasicTeam)
gmChannel := th.CreateGroupChannel(t, th.BasicUser2, user3)
require.Equal(t, model.ChannelTypeGroup, gmChannel.Type)

// This should succeed - group messages allow BoR
post := &model.Post{
ChannelId: gmChannel.Id,
Message: "This is a burn-on-read message in GM",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}

createdPost, _, err := th.App.CreatePost(th.Context, post, gmChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})

t.Run("should allow burn-on-read posts from bot users", func(t *testing.T) {
// Create a bot user
bot := &model.Bot{
Username: "testbot",
DisplayName: "Test Bot",
Description: "Test Bot for burn-on-read (bots can send BoR for OTP, integrations, etc.)",
OwnerId: th.BasicUser.Id,
}
createdBot, appErr := th.App.CreateBot(th.Context, bot)
require.Nil(t, appErr)

// Get the bot user
botUser, appErr := th.App.GetUser(createdBot.UserId)
require.Nil(t, appErr)
require.True(t, botUser.IsBot)

// Create a burn-on-read post as bot (should succeed - bots can send BoR)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This is a burn-on-read message from bot",
UserId: botUser.Id,
Type: model.PostTypeBurnOnRead,
}

createdPost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})

t.Run("should reject burn-on-read posts in self DMs", func(t *testing.T) {
// Create a self DM channel (user messaging themselves)
selfDMChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser.Id)
require.Nil(t, appErr)
require.Equal(t, model.ChannelTypeDirect, selfDMChannel.Type)

// Try to create a burn-on-read post in self DM
post := &model.Post{
ChannelId: selfDMChannel.Id,
Message: "This is a burn-on-read message to myself",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}

_, _, err := th.App.CreatePost(th.Context, post, selfDMChannel, model.CreatePostFlags{SetOnline: true})
require.NotNil(t, err)
require.Equal(t, "api.post.fill_in_post_props.burn_on_read.self_dm.app_error", err.Id)
})

t.Run("should reject burn-on-read posts in DMs with bots/AI agents", func(t *testing.T) {
// Create a bot user
bot := &model.Bot{
Username: "aiagent",
DisplayName: "AI Agent",
Description: "Test AI Agent for burn-on-read restrictions",
OwnerId: th.BasicUser.Id,
}
createdBot, appErr := th.App.CreateBot(th.Context, bot)
require.Nil(t, appErr)

// Get the bot user
botUser, appErr := th.App.GetUser(createdBot.UserId)
require.Nil(t, appErr)
require.True(t, botUser.IsBot)

// Create a DM channel between the regular user and the bot
dmWithBotChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, botUser.Id)
require.Nil(t, appErr)
require.Equal(t, model.ChannelTypeDirect, dmWithBotChannel.Type)

// Try to create a burn-on-read post in DM with bot (regular user sending)
post := &model.Post{
ChannelId: dmWithBotChannel.Id,
Message: "This is a burn-on-read message to AI agent",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}

_, _, err := th.App.CreatePost(th.Context, post, dmWithBotChannel, model.CreatePostFlags{SetOnline: true})
require.NotNil(t, err)
require.Equal(t, "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error", err.Id)
})

t.Run("should reject burn-on-read posts in DMs with deleted users", func(t *testing.T) {
// Create a user that we'll delete
userToDelete := th.CreateUser(t)
th.LinkUserToTeam(t, userToDelete, th.BasicTeam)

// Create a DM channel between the regular user and the user we'll delete
dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, userToDelete.Id)
require.Nil(t, appErr)
require.Equal(t, model.ChannelTypeDirect, dmChannel.Type)

// Delete the user
appErr = th.App.PermanentDeleteUser(th.Context, userToDelete)
require.Nil(t, appErr)

// Try to create a burn-on-read post in DM with deleted user
post := &model.Post{
ChannelId: dmChannel.Id,
Message: "This is a burn-on-read message to deleted user",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}

// This should fail because we can't validate the other user (deleted)
_, _, err := th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{SetOnline: true})
require.NotNil(t, err)
require.Equal(t, "api.post.fill_in_post_props.burn_on_read.user.app_error", err.Id)
})

t.Run("should allow burn-on-read posts in public channels", func(t *testing.T) {
// This should succeed - public channel, regular user
require.Equal(t, model.ChannelTypeOpen, th.BasicChannel.Type)

post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This is a burn-on-read message in public channel",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}

createdPost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})

t.Run("should allow burn-on-read posts in private channels", func(t *testing.T) {
// Create a private channel using helper
createdPrivateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
require.Equal(t, model.ChannelTypePrivate, createdPrivateChannel.Type)

// This should succeed - private channel, regular user
post := &model.Post{
ChannelId: createdPrivateChannel.Id,
Message: "This is a burn-on-read message in private channel",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}

createdPost, _, err := th.App.CreatePost(th.Context, post, createdPrivateChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})
}

func TestGetBurnOnReadPost(t *testing.T) {
t.Run("success - temporary post found", func(t *testing.T) {
th := Setup(t).InitBasic(t)
Expand Down
13 changes: 13 additions & 0 deletions server/channels/app/scheduled_post_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,19 @@ func (a *App) canPostScheduledPost(rctx request.CTX, scheduledPost *model.Schedu
return model.ScheduledPostErrorInvalidPost, nil
}

// Validate burn-on-read restrictions for scheduled post
if appErr := PostBurnOnReadCheckWithApp("ScheduledPostJob.postChecks", a, rctx, scheduledPost.UserId, scheduledPost.ChannelId, scheduledPost.Type, channel); appErr != nil {
rctx.Logger().Debug(
"canPostScheduledPost burn-on-read check failed",
mlog.String("scheduled_post_id", scheduledPost.Id),
mlog.String("user_id", scheduledPost.UserId),
mlog.String("channel_id", scheduledPost.ChannelId),
mlog.String("error_code", model.ScheduledPostErrorInvalidPost),
mlog.Err(appErr),
)
return model.ScheduledPostErrorInvalidPost, nil
}

return "", nil
}

Expand Down
16 changes: 16 additions & 0 deletions server/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2800,6 +2800,14 @@
"id": "api.post.error_get_post_id.pending",
"translation": "Unable to get the pending post."
},
{
"id": "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error",
"translation": "Burn-on-read posts are not allowed in direct messages with bots or AI agents."
},
{
"id": "api.post.fill_in_post_props.burn_on_read.channel.app_error",
"translation": "An error occurred while validating the channel for burn-on-read post."
},
{
"id": "api.post.fill_in_post_props.burn_on_read.config.app_error",
"translation": "Burn-on-read posts are not enabled. Please enable the feature flag and service setting."
Expand All @@ -2808,6 +2816,14 @@
"id": "api.post.fill_in_post_props.burn_on_read.license.app_error",
"translation": "Burn-on-read posts require an Enterprise Advanced license."
},
{
"id": "api.post.fill_in_post_props.burn_on_read.self_dm.app_error",
"translation": "Burn-on-read posts are not allowed when messaging yourself."
},
{
"id": "api.post.fill_in_post_props.burn_on_read.user.app_error",
"translation": "An error occurred while validating the user for burn-on-read post."
},
{
"id": "api.post.fill_in_post_props.invalid_ai_generated_user.app_error",
"translation": "The AI-generated user must be either the post creator or a bot."
Expand Down
Loading