← THE INDEX  ·  WRITEUP

Mattermost Shared Channel Invite API Missing Channel-Level Authorization

The invite and uninvite endpoints for shared channels enforce a system-level permission but skip the channel-level membership check. A user with manage_shared_channels can exfiltrate any private channel to a remote cluster they control.

Summary

Mattermost's shared channels feature allows a user with the manage_shared_channels permission to invite remote clusters to specific channels, enabling cross-instance message synchronization. The inviteRemoteClusterToChannel and uninviteRemoteClusterToChannel handlers in server/channels/api4/shared_channel.go check only the system-level ManageSharedChannels permission via RequirePermissionToManageSharedChannels(). They do not verify that the calling user has any access to the specific channel being shared.

The manage_shared_channels permission can be granted to non-sysadmin users via the SharedChannelManager role. A user with that role and no membership in a private channel can call the invite endpoint for that channel, causing the server to begin synchronizing all of its messages to the attacker's controlled remote cluster.

The bug is a textbook authorization scope mismatch. In the same source file, getSharedChannelRemotes() (line 265) correctly calls SessionHasPermissionToChannel() before returning data. The invite and uninvite handlers lack this call entirely. The same gap exists in the Plugin API (PluginAPI.InviteRemoteToChannel at plugin_api.go:1504), where no authorization check of any kind is performed, and in the service layer, which also accepts and processes the invite without checking channel membership.

This is the same bug class as CVE-2025-11777, where a system-level permission was used where a channel-level check was required. That CVE is prior art for the pattern; this report is an independent instance affecting a different set of endpoints.

Impact

A non-sysadmin user who holds the manage_shared_channels permission can exfiltrate the full message history and ongoing messages of any private channel on the instance by inviting their controlled remote cluster to it.

Exfiltrated data includes: - Complete message history for the channel (synced on invite) - File attachments - User metadata for channel members - Real-time message synchronization going forward

The uninvite endpoint with the same missing check also allows the attacker to remove legitimate remote clusters from authorized shared channels, disrupting cross-instance collaboration.

A secondary attack surface exists via the Plugin API: any installed plugin can share any channel with any remote cluster without any permission check at all.

The service layer's shareIfNotShared=true parameter silently converts a previously unshared private channel into a shared channel before inviting the remote, leaving no obvious trace in the channel's normal UI.

Root cause

In server/channels/api4/shared_channel.go, the invite handler:

func inviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.Request) {
    c.RequireRemoteId()
    c.RequireChannelId()
    c.RequirePermissionToManageSharedChannels()  // system-level check only
    // MISSING:
    // if ok, _ := c.App.SessionHasPermissionToChannel(
    //     c.AppContext, *c.AppContext.Session(),
    //     c.Params.ChannelId, model.PermissionReadChannel); !ok {
    //     c.SetPermissionError(model.PermissionReadChannel)
    //     return
    // }
    ...
    c.App.InviteRemoteToChannel(c.Params.ChannelId, c.Params.RemoteId, ...)
}

RequirePermissionToManageSharedChannels() calls SessionHasPermissionTo(), which checks a PermissionScopeSystem permission. It confirms the user can manage shared channels globally, but does not check whether the user can read the specific channel identified by c.Params.ChannelId.

The gap propagates through the entire call chain: the app layer, the plugin API, and the service layer all pass the channel ID through without any membership check.

Proof of concept

Verified on Docker using mattermost/mattermost-preview:latest (v11.5.1). All identifiers and tokens have been replaced with placeholders.

Disclosure and fix

Reported to the Mattermost security team. The fix requires adding a channel-level permission check in both the invite and uninvite handlers, after the existing system-level check:

// After RequirePermissionToManageSharedChannels() in both handlers:
if ok, _ := c.App.SessionHasPermissionToChannel(
    c.AppContext, *c.AppContext.Session(),
    c.Params.ChannelId, model.PermissionReadChannel); !ok {
    c.SetPermissionError(model.PermissionReadChannel)
    return
}

The same check should be added to: - uninviteRemoteClusterToChannel (same file) - PluginAPI.InviteRemoteToChannel (plugin_api.go) - PluginAPI.UninviteRemoteFromChannel (plugin_api.go)

The getSharedChannelRemotes handler in the same file already implements this pattern correctly and serves as the reference implementation.