Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
323 lines
13 KiB
Go
323 lines
13 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package api4
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
|
|
eMocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
|
|
)
|
|
|
|
// These tests cover the endpoints that build their own file metadata instead of going
|
|
// through sanitizeFileAttachmentsForUser. With the channel's Access Control Policy denying
|
|
// download_file_attachment, every direct file endpoint returns 403, and none of these
|
|
// endpoints may hand back the file's rendered thumbnail for the same file, user and moment.
|
|
//
|
|
// Sent posts and their historical versions are redacted outright. Drafts and scheduled posts
|
|
// keep their metadata, because the author still has to see and send their own unsent
|
|
// content, and only the thumbnail is withheld.
|
|
//
|
|
// The policy is switched to deny only after the upload, which is the case Access Control
|
|
// Policies exist to cover: revoking access to content the user could previously reach.
|
|
|
|
// setFileDownloadPolicy enables ABAC and installs an access control service that returns
|
|
// the given decision for download_file_attachment while allowing every other action.
|
|
func setFileDownloadPolicy(t *testing.T, th *TestHelper, allowed bool) *eMocks.AccessControlServiceInterface {
|
|
t.Helper()
|
|
|
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
|
|
})
|
|
|
|
mockACS := &eMocks.AccessControlServiceInterface{}
|
|
original := th.App.Srv().Channels().AccessControl
|
|
th.App.Srv().Channels().AccessControl = mockACS
|
|
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = original })
|
|
|
|
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
|
|
return req.Action == model.AccessControlPolicyActionDownloadFileAttachment
|
|
})).Return(model.AccessDecision{Decision: allowed}, (*model.AppError)(nil))
|
|
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
|
|
Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil)).Maybe()
|
|
|
|
// Channel teardown and policy reads touch the service without being part of the
|
|
// behaviour under test.
|
|
mockACS.On("GetPolicy", mock.Anything, mock.AnythingOfType("string")).
|
|
Return(nil, (*model.AppError)(nil)).Maybe()
|
|
mockACS.On("DeletePolicy", mock.Anything, mock.AnythingOfType("string")).
|
|
Return((*model.AppError)(nil)).Maybe()
|
|
|
|
return mockACS
|
|
}
|
|
|
|
// requireDirectFileEndpointsDenied is the precondition shared by every case below: the
|
|
// endpoints that already enforce the policy must reject this user for this file.
|
|
func requireDirectFileEndpointsDenied(t *testing.T, th *TestHelper, fileID string) {
|
|
t.Helper()
|
|
|
|
_, resp, err := th.Client.GetFile(context.Background(), fileID)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
_, resp, err = th.Client.GetFileThumbnail(context.Background(), fileID)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
_, resp, err = th.Client.GetFilePreview(context.Background(), fileID)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
_, resp, err = th.Client.GetFileInfo(context.Background(), fileID)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
}
|
|
|
|
// requireNoThumbnailLeaked asserts that no rendered thumbnail reached a user the policy
|
|
// denies. This is the file's actual content, so no endpoint may serve it.
|
|
func requireNoThumbnailLeaked(t *testing.T, metadata *model.PostMetadata, where string) {
|
|
t.Helper()
|
|
|
|
if metadata == nil {
|
|
return
|
|
}
|
|
|
|
for _, info := range metadata.Files {
|
|
if info.MiniPreview != nil {
|
|
require.Failf(t, "leaked a thumbnail", "%s: returned a %d byte mini preview for file %s", where, len(*info.MiniPreview), info.Id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// requireNoLeakedFileMetadata additionally asserts that no file metadata survived, which is
|
|
// what sent posts and their historical versions must do.
|
|
func requireNoLeakedFileMetadata(t *testing.T, metadata *model.PostMetadata, where string) {
|
|
t.Helper()
|
|
|
|
requireNoThumbnailLeaked(t, metadata, where)
|
|
|
|
if metadata == nil {
|
|
return
|
|
}
|
|
require.Emptyf(t, metadata.Files, "%s: leaked file metadata to a user denied download_file_attachment", where)
|
|
}
|
|
|
|
func uploadTestImage(t *testing.T, th *TestHelper, channelID, name string) *model.FileInfo {
|
|
t.Helper()
|
|
|
|
data, err := testutils.ReadTestFile("test.png")
|
|
require.NoError(t, err)
|
|
|
|
uploaded, _, err := th.Client.UploadFile(context.Background(), data, channelID, name)
|
|
require.NoError(t, err)
|
|
require.Len(t, uploaded.FileInfos, 1)
|
|
require.NotNil(t, uploaded.FileInfos[0].MiniPreview, "precondition: the uploaded image should have a mini preview to leak")
|
|
|
|
return uploaded.FileInfos[0]
|
|
}
|
|
|
|
func TestGetDraftsWithholdsThumbnailWhenPolicyDenies(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.PermissionPolicies = true
|
|
}).InitBasic(t)
|
|
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
|
|
|
|
info := uploadTestImage(t, th, th.BasicChannel.Id, "draft-attachment.png")
|
|
|
|
_, _, err := th.Client.UpsertDraft(context.Background(), &model.Draft{
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "draft with a gated attachment",
|
|
FileIds: model.StringArray{info.Id},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
setFileDownloadPolicy(t, th, false)
|
|
requireDirectFileEndpointsDenied(t, th, info.Id)
|
|
|
|
drafts, _, err := th.Client.GetDrafts(context.Background(), th.BasicUser.Id, th.BasicTeam.Id)
|
|
require.NoError(t, err)
|
|
require.Len(t, drafts, 1)
|
|
|
|
requireNoThumbnailLeaked(t, drafts[0].Metadata, "GET /users/me/teams/{team_id}/drafts")
|
|
|
|
// The author must still be able to see and send their own unsent draft. The web client
|
|
// builds the outgoing post's file_ids from metadata.files, so dropping the metadata
|
|
// would silently strip the attachment when the draft is sent.
|
|
require.Len(t, drafts[0].Metadata.Files, 1, "the author should still see their own draft attachment")
|
|
require.Equal(t, info.Id, drafts[0].Metadata.Files[0].Id)
|
|
require.Equal(t, model.StringArray{info.Id}, drafts[0].FileIds)
|
|
}
|
|
|
|
func TestGetScheduledPostsWithholdsThumbnailWhenPolicyDenies(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.PermissionPolicies = true
|
|
}).InitBasic(t)
|
|
|
|
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
|
|
|
info := uploadTestImage(t, th, th.BasicChannel.Id, "scheduled-attachment.png")
|
|
|
|
created, _, err := th.Client.CreateScheduledPost(context.Background(), &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "scheduled post with a gated attachment",
|
|
FileIds: model.StringArray{info.Id},
|
|
},
|
|
ScheduledAt: model.GetMillis() + 100000,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, created)
|
|
|
|
setFileDownloadPolicy(t, th, false)
|
|
requireDirectFileEndpointsDenied(t, th, info.Id)
|
|
|
|
scheduledPosts, _, err := th.Client.GetUserScheduledPosts(context.Background(), th.BasicTeam.Id, false)
|
|
require.NoError(t, err)
|
|
require.Len(t, scheduledPosts[th.BasicTeam.Id], 1)
|
|
|
|
scheduled := scheduledPosts[th.BasicTeam.Id][0]
|
|
requireNoThumbnailLeaked(t, scheduled.Metadata, "GET /posts/scheduled/team/{team_id}")
|
|
|
|
// The server sends this post from the stored FileIds when it fires, so dropping the
|
|
// metadata would make a client-side "send now" and the scheduled send disagree.
|
|
require.Len(t, scheduled.Metadata.Files, 1, "the author should still see their own scheduled attachment")
|
|
require.Equal(t, info.Id, scheduled.Metadata.Files[0].Id)
|
|
require.Equal(t, model.StringArray{info.Id}, scheduled.FileIds)
|
|
}
|
|
|
|
func TestGetEditHistoryForPostDoesNotLeakFilesDeniedByPolicy(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.PermissionPolicies = true
|
|
}).InitBasic(t)
|
|
|
|
info := uploadTestImage(t, th, th.BasicChannel.Id, "edit-history-attachment.png")
|
|
|
|
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "post with a gated attachment",
|
|
FileIds: model.StringArray{info.Id},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, _, err = th.Client.PatchPost(context.Background(), post.Id, &model.PostPatch{
|
|
Message: new("post with a gated attachment, edited"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
setFileDownloadPolicy(t, th, false)
|
|
requireDirectFileEndpointsDenied(t, th, info.Id)
|
|
|
|
// The live post is redacted for this user. The historical versions of the same post are
|
|
// expected to match that, and are the control that proves the redaction works at all.
|
|
live, _, err := th.Client.GetPost(context.Background(), post.Id, "")
|
|
require.NoError(t, err)
|
|
requireNoLeakedFileMetadata(t, live.Metadata, "GET /posts/{post_id} (control)")
|
|
require.Empty(t, live.FileIds, "control: the live post should not expose file ids either")
|
|
|
|
history, _, err := th.Client.GetEditHistoryForPost(context.Background(), post.Id)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, history)
|
|
|
|
for _, version := range history {
|
|
require.NotNil(t, version.Metadata)
|
|
requireNoLeakedFileMetadata(t, version.Metadata, "GET /posts/{post_id}/edit_history")
|
|
require.Empty(t, version.FileIds, "the historical version should not expose file ids either")
|
|
require.Equal(t, 1, version.Metadata.RedactedFileCount)
|
|
}
|
|
}
|
|
|
|
// TestFileMetadataServedWhenPolicyAllows is the other half of the contract. With a policy
|
|
// active but allowing the action, all three endpoints must still serve the metadata and the
|
|
// thumbnail, so enforcement cannot be satisfied by redacting unconditionally.
|
|
func TestFileMetadataServedWhenPolicyAllows(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.PermissionPolicies = true
|
|
}).InitBasic(t)
|
|
|
|
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
|
|
|
|
requireFileServed := func(metadata *model.PostMetadata, fileID, where string) {
|
|
t.Helper()
|
|
|
|
require.NotNil(t, metadata, where)
|
|
require.Len(t, metadata.Files, 1, "%s: should serve the file metadata", where)
|
|
require.Equal(t, fileID, metadata.Files[0].Id, where)
|
|
require.NotNil(t, metadata.Files[0].MiniPreview, "%s: should serve the thumbnail", where)
|
|
require.Zero(t, metadata.RedactedFileCount, where)
|
|
}
|
|
|
|
draftFile := uploadTestImage(t, th, th.BasicChannel.Id, "draft-allowed.png")
|
|
_, _, err := th.Client.UpsertDraft(context.Background(), &model.Draft{
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "draft with an allowed attachment",
|
|
FileIds: model.StringArray{draftFile.Id},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
scheduledFile := uploadTestImage(t, th, th.BasicChannel.Id, "scheduled-allowed.png")
|
|
_, _, err = th.Client.CreateScheduledPost(context.Background(), &model.ScheduledPost{
|
|
Draft: model.Draft{
|
|
CreateAt: model.GetMillis(),
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "scheduled post with an allowed attachment",
|
|
FileIds: model.StringArray{scheduledFile.Id},
|
|
},
|
|
ScheduledAt: model.GetMillis() + 100000,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
postFile := uploadTestImage(t, th, th.BasicChannel.Id, "edit-history-allowed.png")
|
|
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "post with an allowed attachment",
|
|
FileIds: model.StringArray{postFile.Id},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, _, err = th.Client.PatchPost(context.Background(), post.Id, &model.PostPatch{
|
|
Message: new("post with an allowed attachment, edited"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
setFileDownloadPolicy(t, th, true)
|
|
|
|
_, _, err = th.Client.GetFileThumbnail(context.Background(), postFile.Id)
|
|
require.NoError(t, err, "precondition: the policy should allow download_file_attachment")
|
|
|
|
drafts, _, err := th.Client.GetDrafts(context.Background(), th.BasicUser.Id, th.BasicTeam.Id)
|
|
require.NoError(t, err)
|
|
require.Len(t, drafts, 1)
|
|
requireFileServed(drafts[0].Metadata, draftFile.Id, "GET /users/me/teams/{team_id}/drafts")
|
|
|
|
scheduledPosts, _, err := th.Client.GetUserScheduledPosts(context.Background(), th.BasicTeam.Id, false)
|
|
require.NoError(t, err)
|
|
require.Len(t, scheduledPosts[th.BasicTeam.Id], 1)
|
|
requireFileServed(scheduledPosts[th.BasicTeam.Id][0].Metadata, scheduledFile.Id, "GET /posts/scheduled/team/{team_id}")
|
|
|
|
history, _, err := th.Client.GetEditHistoryForPost(context.Background(), post.Id)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, history)
|
|
|
|
for _, version := range history {
|
|
requireFileServed(version.Metadata, postFile.Id, "GET /posts/{post_id}/edit_history")
|
|
require.Equal(t, model.StringArray{postFile.Id}, version.FileIds)
|
|
}
|
|
}
|