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 api/v4/source/access_control.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,16 @@
$ref: "#/components/responses/InternalServerError"
"/api/v4/access_control_policies/{policy_id}/activate":
get:
deprecated: true
tags:
- access control
summary: Activate or deactivate an access control policy
description: |
Updates the active status of an access control policy.

**Deprecated:** This endpoint will be removed in a future release. Use the dedicated access control policy update endpoint instead.
Link: </api/v4/access_control_policies/activate>; rel="successor-version"

##### Permissions
Must have the `manage_system` permission.
operationId: UpdateAccessControlPolicyActiveStatus
Expand Down
2 changes: 1 addition & 1 deletion server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ ifneq ($(IGNORE_GO_WORK_IF_EXISTS),true)
$(GO) work use .
$(GO) work use ./public
ifeq ($(BUILD_ENTERPRISE_READY),true)
$(GO) work use ../../enterprise
$(GO) work use $(BUILD_ENTERPRISE_DIR)
endif
endif

Expand Down
26 changes: 22 additions & 4 deletions server/channels/api4/access_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,12 +382,22 @@ func searchAccessControlPolicies(c *Context, w http.ResponseWriter, r *http.Requ
}
}

// updateActiveStatus updates the active status of a single access control policy.
//
// Deprecated: This endpoint is deprecated and will be removed in a future release.
// Use PUT /api/v4/access_control/policies/activate instead, which supports batch updates.
func updateActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
if c.Err != nil {
return
}

// CSRF barrier: only allow header-based auth (reject cookie-only sessions)
if r.Header.Get(model.HeaderAuth) == "" {
c.SetInvalidParam("Authorization")
return
}

policyID := c.Params.PolicyId

// Check if user has system admin permission OR channel-specific permission for this policy
Expand Down Expand Up @@ -416,7 +426,11 @@ func updateActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
}
model.AddEventParameterToAuditRec(auditRec, "active", activeBool)

appErr := c.App.UpdateAccessControlPolicyActive(c.AppContext, policyID, activeBool)
// Wrap single update in slice to use the batch update method
updates := []model.AccessControlPolicyActiveUpdate{
{ID: policyID, Active: activeBool},
}
_, appErr := c.App.UpdateAccessControlPoliciesActive(c.AppContext, updates)
if appErr != nil {
c.Err = appErr
return
Expand All @@ -429,6 +443,9 @@ func updateActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
"status": "OK",
}

// Set deprecation header to inform clients
w.Header().Set("Deprecation", "true")
w.Header().Set("Link", "</api/v4/access_control/policies/activate>; rel=\"successor-version\"")
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
Expand All @@ -446,12 +463,13 @@ func setActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "requested", &list)

// Check if user has system admin permission OR channel-specific permission for this policy
// Check if user has system admin permission OR policy-specific permission
hasManageSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasManageSystemPermission {
for _, entry := range list.Entries {
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, entry.ID, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
// Validate policy access permission - this fetches the policy first to verify it exists
// and is a channel-type policy before checking channel permissions
if appErr := c.App.ValidateAccessControlPolicyPermission(c.AppContext, c.AppContext.Session().UserId, entry.ID); appErr != nil {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
}
Expand Down
1 change: 0 additions & 1 deletion server/channels/api4/access_control_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ func (api *API) InitAccessControlPolicyLocal() {

api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(getAccessControlPolicy)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(deleteAccessControlPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.AccessControlPolicy.Handle("/activate", api.APILocal(updateActiveStatus)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("/assign", api.APILocal(assignAccessPolicy)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicy.Handle("/unassign", api.APILocal(unassignAccessPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels", api.APILocal(getChannelsForAccessControlPolicy)).Methods(http.MethodGet)
Expand Down
62 changes: 62 additions & 0 deletions server/channels/api4/access_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,7 @@ func TestSetActiveStatus(t *testing.T) {
}

mockAccessControlService := &mocks.AccessControlServiceInterface{}
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), privateChannel.Id).Return(channelPolicy, nil)
th.App.Srv().Channels().AccessControl = mockAccessControlService

// Channel admin should be able to set active status for their channel
Expand All @@ -974,4 +975,65 @@ func TestSetActiveStatus(t *testing.T) {
require.Equal(t, channelPolicy.ID, policies[0].ID, "expected policy ID to match")
require.True(t, policies[0].Active, "expected policy to be active")
})

t.Run("SetActiveStatus with channel admin for another channel should fail", func(t *testing.T) {
// This test verifies the security fix: a channel admin cannot modify the active status
// of a policy for a channel they don't have permissions on, even if they attempt to
// use a policy ID that matches a channel they control.
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})

ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")

// Add permission to channel admin role
th.AddPermissionToRole(t, model.PermissionManageChannelAccessRules.Id, model.ChannelAdminRoleId)

// Create two private channels
channelA := th.CreatePrivateChannel(t)
channelB := th.CreatePrivateChannel(t)

// Create a channel admin who only has access to channel A
channelAdmin := th.CreateUser(t)
th.LinkUserToTeam(t, channelAdmin, th.BasicTeam)
th.AddUserToChannel(t, channelAdmin, channelA)
th.MakeUserChannelAdmin(t, channelAdmin, channelA)

// Create a policy for channel B (which the channel admin does NOT have access to)
channelBPolicy := &model.AccessControlPolicy{
ID: channelB.Id,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, channelBPolicy)
require.NoError(t, err)

channelAdminClient := th.CreateClient()
_, _, err = channelAdminClient.Login(context.Background(), channelAdmin.Email, channelAdmin.Password)
require.NoError(t, err)

mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), channelB.Id).Return(channelBPolicy, nil)

// Attempt to update the policy for channel B (which the admin doesn't have access to)
maliciousUpdateReq := model.AccessControlPolicyActiveUpdateRequest{
Entries: []model.AccessControlPolicyActiveUpdate{
{ID: channelB.Id, Active: true},
},
}

// Channel admin should NOT be able to set active status for another channel's policy
_, resp, err := channelAdminClient.SetAccessControlPolicyActive(context.Background(), maliciousUpdateReq)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
9 changes: 0 additions & 9 deletions server/channels/app/access_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,15 +302,6 @@ func (a *App) GetAccessControlFieldsAutocomplete(rctx request.CTX, after string,
return fields, nil
}

func (a *App) UpdateAccessControlPolicyActive(rctx request.CTX, policyID string, active bool) *model.AppError {
_, err := a.Srv().Store().AccessControlPolicy().SetActiveStatus(rctx, policyID, active)
if err != nil {
return model.NewAppError("UpdateAccessControlPolicyActive", "app.pap.update_access_control_policy_active.app_error", nil, err.Error(), http.StatusInternalServerError)
}

return nil
}

func (a *App) UpdateAccessControlPoliciesActive(rctx request.CTX, updates []model.AccessControlPolicyActiveUpdate) ([]*model.AccessControlPolicy, *model.AppError) {
acs := a.Srv().ch.AccessControl
if acs == nil {
Expand Down
3 changes: 2 additions & 1 deletion server/channels/app/integration_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID,
defer resp.Body.Close()

var response model.PostActionIntegrationResponse
respBytes, err := io.ReadAll(resp.Body)
limitedReader := io.LimitReader(resp.Body, MaxIntegrationResponseSize)
respBytes, err := io.ReadAll(limitedReader)
if err != nil {
return "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
Expand Down
114 changes: 113 additions & 1 deletion server/channels/app/integration_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,118 @@ func TestPostActionEmptyResponse(t *testing.T) {
})
}

// infiniteReader generates unlimited data for testing response size limits
type infiniteReader struct{}

func (r infiniteReader) Read(p []byte) (n int, err error) {
for i := range p {
p[i] = 'a'
}
return len(p), nil
}

// MM-67074: TestPostActionResponseSizeLimit verifies that DoPostActionWithCookie
// properly limits response sizes to prevent OOM attacks
func TestPostActionResponseSizeLimit(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)

channel := th.BasicChannel
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
})

t.Run("large valid JSON response is truncated", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Send response larger than MaxIntegrationResponseSize (1MB)
// Response starts as valid JSON but becomes truncated
_, _ = io.Copy(w, io.MultiReader(
strings.NewReader(`{"update":{"message":"`),
infiniteReader{},
strings.NewReader(`"}}`),
))
}))
defer server.Close()

interactivePost := model.Post{
Message: "Interactive post",
ChannelId: channel.Id,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: th.BasicUser.Id,
Props: model.StringInterface{
model.PostPropsAttachments: []*model.SlackAttachment{
{
Text: "hello",
Actions: []*model.PostAction{
{
Type: model.PostActionTypeButton,
Name: "action",
Integration: &model.PostActionIntegration{
URL: server.URL,
},
},
},
},
},
},
}

post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true)
require.Nil(t, err)
attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment)
require.True(t, ok)

// Should return error due to truncated JSON, but NOT crash or OOM
_, err = th.App.DoPostActionWithCookie(th.Context, post.Id,
attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil)
require.NotNil(t, err)
// Truncated JSON causes unmarshal error
assert.Equal(t, "api.post.do_action.action_integration.app_error", err.Id)
})

t.Run("large invalid response is truncated", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Send infinite non-JSON data
_, _ = io.Copy(w, infiniteReader{})
}))
defer server.Close()

interactivePost := model.Post{
Message: "Interactive post",
ChannelId: channel.Id,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: th.BasicUser.Id,
Props: model.StringInterface{
model.PostPropsAttachments: []*model.SlackAttachment{
{
Text: "hello",
Actions: []*model.PostAction{
{
Type: model.PostActionTypeButton,
Name: "action",
Integration: &model.PostActionIntegration{
URL: server.URL,
},
},
},
},
},
},
}

post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true)
require.Nil(t, err)
attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment)
require.True(t, ok)

// Should return error due to invalid JSON, but NOT crash or OOM
_, err = th.App.DoPostActionWithCookie(th.Context, post.Id,
attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil)
require.NotNil(t, err)
assert.Equal(t, "api.post.do_action.action_integration.app_error", err.Id)
})
}

func TestPostAction(t *testing.T) {
mainHelper.Parallel(t)
testCases := []struct {
Expand Down Expand Up @@ -1236,7 +1348,7 @@ func TestLookupInteractiveDialog(t *testing.T) {
func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
var request model.SubmitDialogRequest
json.NewDecoder(r.Body).Decode(&request)

response := &model.LookupDialogResponse{
Items: []model.DialogSelectOption{
{Text: "Plugin Option 1", Value: "plugin_value1"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
package elasticsearch

import (
"bytes"
"context"
"encoding/json"
"testing"

elastic "github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/typedapi/types"
"github.com/stretchr/testify/suite"

"github.com/mattermost/mattermost/server/public/model"
Expand Down Expand Up @@ -171,3 +173,41 @@ func (s *ElasticsearchInterfaceTestSuite) TestSyncBulkIndexChannels() {
s.Require().Contains(appErr.Error(), "test.error")
})
}

func (s *ElasticsearchInterfaceTestSuite) TestTemplateCreationClientError() {
s.Run("Should handle error with CausedBy information from elasticsearch", func() {
// Invalid template request that will trigger an error with caused_by
invalidTemplateBody := map[string]any{
"index_patterns": []string{"test-invalid-*"},
"template": map[string]any{
"settings": map[string]any{
"analysis": map[string]any{
"analyzer": map[string]any{
"my_analyzer": map[string]any{
"type": "custom",
"tokenizer": "nonexistent_tokenizer",
},
},
},
},
},
}

templateBytes, err := json.Marshal(invalidTemplateBody)
s.Require().NoError(err)

_, err = s.client.Indices.PutIndexTemplate("test-invalid-template").
Raw(bytes.NewReader(templateBytes)).
Do(s.ctx)

var esErr *types.ElasticsearchError
s.Require().ErrorAs(err, &esErr)

s.Require().NotNil(esErr.ErrorCause.CausedBy, "Expected CausedBy to be present")
s.Require().NotEmpty(esErr.ErrorCause.CausedBy.Type)
s.Require().NotEmpty(*esErr.ErrorCause.CausedBy.Reason)

// clean up after test
_, _ = s.client.Indices.DeleteIndexTemplate("test-invalid-template").Do(s.ctx)
})
}
Loading
Loading