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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ go.work.sum
.npminstall
.yarninstall
/prepackaged_plugins
tools/sharedchannel-test/sharedchannel-test

# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ test('MM-65630-1 Search results should show popout button that opens results in
await expect(page.locator('#searchContainer')).toBeVisible();
await expect(page.locator('#searchContainer').getByText(uniqueText)).toBeVisible();

const popoutButton = page.locator('.PopoutButton');
const popoutButton = page.locator('#searchContainer .PopoutButton');
await expect(popoutButton).toBeVisible();

const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
Expand Down Expand Up @@ -82,7 +82,7 @@ test('MM-65630-2 Recent mentions popout should open with the right results', asy
await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Recent Mentions'})).toBeVisible();
await expect(page.locator('#searchContainer').getByText(mentionText)).toBeVisible();

const popoutButton = page.locator('.PopoutButton');
const popoutButton = page.locator('#searchContainer .PopoutButton');
await expect(popoutButton).toBeVisible();

const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
Expand Down Expand Up @@ -138,7 +138,7 @@ test('MM-65630-3 Saved messages popout should open with the right results', asyn
await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Saved messages'})).toBeVisible();
await expect(page.locator('#searchContainer').getByText(savedText)).toBeVisible();

const popoutButton = page.locator('.PopoutButton');
const popoutButton = page.locator('#searchContainer .PopoutButton');
await expect(popoutButton).toBeVisible();

const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
Expand Down Expand Up @@ -185,7 +185,10 @@ test('MM-65630-4 Search popout should not show popout button in the popout windo

await expect(page.locator('#searchContainer')).toBeVisible();

const [popoutPage] = await Promise.all([page.waitForEvent('popup'), page.locator('.PopoutButton').click()]);
const [popoutPage] = await Promise.all([
page.waitForEvent('popup'),
page.locator('#searchContainer .PopoutButton').click(),
]);

await popoutPage.waitForLoadState('domcontentloaded');
await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000});
Expand Down Expand Up @@ -224,7 +227,7 @@ test('MM-65630-5 Search popout should preserve search type (files) in the URL',
const filesTab = page.locator('#searchContainer').getByRole('tab', {name: /Files/});
await filesTab.click();

const popoutButton = page.locator('.PopoutButton');
const popoutButton = page.locator('#searchContainer .PopoutButton');
await expect(popoutButton).toBeVisible();

const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
Expand Down
141 changes: 141 additions & 0 deletions server/channels/api4/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package api4
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
Expand All @@ -17,6 +18,7 @@ import (

"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)

func TestCreateCommand(t *testing.T) {
Expand Down Expand Up @@ -1389,14 +1391,17 @@ func TestExecuteInvalidCommand(t *testing.T) {

enableCommands := *th.App.Config().ServiceSettings.EnableCommands
allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
siteURL := *th.App.Config().ServiceSettings.SiteURL
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections
})
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = &siteURL })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.0/8" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rc := &model.CommandResponse{}
Expand Down Expand Up @@ -1461,14 +1466,17 @@ func TestExecuteGetCommand(t *testing.T) {

enableCommands := *th.App.Config().ServiceSettings.EnableCommands
allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
siteURL := *th.App.Config().ServiceSettings.SiteURL
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections
})
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = &siteURL })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.0/8" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })

token := model.NewId()
expectedCommandResponse := &model.CommandResponse{
Expand Down Expand Up @@ -1523,14 +1531,17 @@ func TestExecutePostCommand(t *testing.T) {

enableCommands := *th.App.Config().ServiceSettings.EnableCommands
allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
siteURL := *th.App.Config().ServiceSettings.SiteURL
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections
})
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = &siteURL })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.0/8" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })

token := model.NewId()
expectedCommandResponse := &model.CommandResponse{
Expand Down Expand Up @@ -1692,16 +1703,19 @@ func TestExecuteCommandInDirectMessageChannel(t *testing.T) {

enableCommands := *th.App.Config().ServiceSettings.EnableCommands
allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
siteURL := *th.App.Config().ServiceSettings.SiteURL
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections
})
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = &siteURL })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true })
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
})
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })

// create a team that the user isn't a part of
team2 := th.CreateTeam(t)
Expand Down Expand Up @@ -1756,16 +1770,19 @@ func TestExecuteCommandInTeamUserIsNotOn(t *testing.T) {

enableCommands := *th.App.Config().ServiceSettings.EnableCommands
allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
siteURL := *th.App.Config().ServiceSettings.SiteURL
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections
})
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = &siteURL })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true })
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
})
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })

// create a team that the user isn't a part of
team2 := th.CreateTeam(t)
Expand Down Expand Up @@ -1833,16 +1850,19 @@ func TestExecuteCommandReadOnly(t *testing.T) {

enableCommands := *th.App.Config().ServiceSettings.EnableCommands
allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
siteURL := *th.App.Config().ServiceSettings.SiteURL
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections
})
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = &siteURL })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true })
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
})
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })

expectedCommandResponse := &model.CommandResponse{
Text: "test post command response",
Expand Down Expand Up @@ -1920,3 +1940,124 @@ func TestExecuteCommandReadOnly(t *testing.T) {
require.Error(t, err)
CheckBadRequestStatus(t, resp)
}

func TestExecuteCommandResponseURLUsesSiteURL(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel

enableCommands := *th.App.Config().ServiceSettings.EnableCommands
allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
siteURL := *th.App.Config().ServiceSettings.SiteURL
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections
})
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = &siteURL })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.0/8" })

// Set a SiteURL that differs from the test client's Host header (localhost).
// This verifies that response_url uses the configured SiteURL, not the Host header.
// Before the fix (MM-67142), response_url would contain "localhost" and fail this check.
expectedSiteURL := "http://mattermost.example.com"
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = expectedSiteURL })

var receivedResponseURL string
expectedCommandResponse := &model.CommandResponse{
Text: "test response_url command response",
ResponseType: model.CommandResponseTypeInChannel,
}

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
require.NoError(t, err)

// Capture the response_url sent by the server
receivedResponseURL = r.FormValue("response_url")

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(expectedCommandResponse); err != nil {
th.TestLogger.Warn("Error while writing response", mlog.Err(err))
}
}))
defer ts.Close()

postCmd := &model.Command{
CreatorId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
URL: ts.URL,
Method: model.CommandMethodPost,
Trigger: "testrespurl",
}

_, appErr := th.App.CreateCommand(postCmd)
require.Nil(t, appErr, "failed to create command")

_, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/testrespurl")
require.NoError(t, err)

// Verify response_url starts with the configured SiteURL, not the Host header
require.True(t, strings.HasPrefix(receivedResponseURL, expectedSiteURL),
"response_url should start with configured SiteURL %q, but got %q", expectedSiteURL, receivedResponseURL)

// Verify warning is logged when Host header differs from SiteURL
require.NoError(t, th.TestLogger.Flush())
testlib.AssertLog(t, th.LogBuffer, mlog.LvlWarn.Name, "Request hostname differs from configured SiteURL. The configured SiteURL will be used for the slash command response URL.")
}

func TestExecuteCustomCommandRequiresSiteURL(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
channel := th.BasicChannel

enableCommands := *th.App.Config().ServiceSettings.EnableCommands
allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
siteURL := *th.App.Config().ServiceSettings.SiteURL
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections
})
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = &siteURL })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.0/8" })

// Set SiteURL to a valid value first so we can create the command
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })

// Create a custom command
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"text": "response"}`)
}))
defer ts.Close()

postCmd := &model.Command{
CreatorId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
URL: ts.URL,
Method: model.CommandMethodPost,
Trigger: "customcmd",
}
_, appErr := th.App.CreateCommand(postCmd)
require.Nil(t, appErr, "failed to create command")

// Now set SiteURL to empty
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "" })

// Try to execute the custom command - should fail because SiteURL is required for custom commands
_, resp, err := client.ExecuteCommand(context.Background(), channel.Id, "/customcmd")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.command.execute_command.site_url_required.app_error")

// Built-in commands should still work without SiteURL
_, _, err = client.ExecuteCommand(context.Background(), channel.Id, "/echo test")
require.NoError(t, err)
}
14 changes: 13 additions & 1 deletion server/channels/app/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,11 +497,23 @@ func (a *App) tryExecuteCustomCommand(rctx request.CTX, args *model.CommandArgs,
channelMentionMap := a.MentionsToPublicChannels(rctx, message, team.Id)
maps.Copy(p, channelMentionMap.ToURLValues())

// Use configured SiteURL for response_url to prevent SSRF via Host header spoofing (MM-67142)
siteURL := *a.Config().ServiceSettings.SiteURL
if siteURL == "" {
return cmd, nil, model.NewAppError("tryExecuteCustomCommand", "api.command.execute_command.site_url_required.app_error", nil, "", http.StatusBadRequest)
}
if siteURL != args.SiteURL {
rctx.Logger().Warn(i18n.T("api.command.execute_command.provided_site_url_different.app_error"),
mlog.String("request_host", args.SiteURL),
mlog.String("configured_site_url", siteURL),
mlog.String("command", trigger))
}

hook, appErr := a.CreateCommandWebhook(cmd.Id, args)
if appErr != nil {
return cmd, nil, model.NewAppError("command", "api.command.execute_command.failed.app_error", map[string]any{"Trigger": trigger}, "", http.StatusInternalServerError).Wrap(appErr)
}
p.Set("response_url", args.SiteURL+"/hooks/commands/"+hook.Id)
p.Set("response_url", siteURL+"/hooks/commands/"+hook.Id)

return a.DoCommandRequest(rctx, cmd, p)
}
Expand Down
7 changes: 6 additions & 1 deletion server/channels/app/command_autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,12 @@ func (a *App) getDynamicListArgument(rctx request.CTX, commandArgs *model.Comman
params.Add("team_id", commandArgs.TeamId)
params.Add("root_id", commandArgs.RootId)
params.Add("user_id", commandArgs.UserId)
params.Add("site_url", commandArgs.SiteURL)

// Use configured SiteURL to prevent SSRF via Host header spoofing (MM-67142)
siteURL := *a.Config().ServiceSettings.SiteURL
if siteURL != "" {
params.Add("site_url", siteURL)
}

resp, err := a.doPluginRequest(rctx, "GET", dynamicArg.FetchURL, params, nil)

Expand Down
16 changes: 16 additions & 0 deletions server/channels/store/localcachelayer/channel_layer.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,22 @@ func (s LocalCacheChannelStore) GetPinnedPostCount(channelId string, allowFromCa
return count, nil
}

func (s LocalCacheChannelStore) Save(rctx request.CTX, channel *model.Channel, maxChannelsPerTeam int64, channelOptions ...model.ChannelOption) (*model.Channel, error) {
newChannel, err := s.ChannelStore.Save(rctx, channel, maxChannelsPerTeam, channelOptions...)
if err == nil {
s.rootStore.doStandardAddToCache(s.rootStore.channelByIdCache, newChannel.Id, newChannel)
}
return newChannel, err
}

func (s LocalCacheChannelStore) Update(rctx request.CTX, channel *model.Channel) (*model.Channel, error) {
updatedChannel, err := s.ChannelStore.Update(rctx, channel)
if err == nil {
s.rootStore.doInvalidateCacheCluster(s.rootStore.channelByIdCache, channel.Id, nil)
}
return updatedChannel, err
}

func (s LocalCacheChannelStore) Get(id string, allowFromCache bool) (*model.Channel, error) {
if allowFromCache {
var cacheItem *model.Channel
Expand Down
Loading