Zion Boggan

In-depth vulnerability research, detection engineering & applied cryptography.

● Open to security-research & detection roles
GitHub · LinkedIn · Email
← Research notebook
Authz bypass

Missing Channel-Level Authorization in Shared Channel Invite/Uninvite API Allows Private Channel Data Exfiltration

Severity: High CVSS Score: 7.7 CWE: CWE-862 Type: Broken Access Control (Authorization Bypass) URL: POST /api/v4/remotecluster/{remote_id}/channels/{channel_id}/invite

Summary

The inviteRemoteClusterToChannel and uninviteRemoteClusterToChannel API endpoints in server/channels/api4/shared_channel.go only enforce system-level manage_shared_channels permission via RequirePermissionToManageSharedChannels() (line 153/204). They do not verify that the calling user has channel-level access (SessionHasPermissionToChannel) to the target channel.

This allows any user who holds the manage_shared_channels permission (granted via the SharedChannelManager role, which is a non-sysadmin role) to invite a remote cluster to any private channel on the instance, including channels they are not a member of and cannot read. Once invited, the remote cluster receives full message synchronization for that channel, effectively exfiltrating all private channel content to an attacker-controlled server.

This is the same bug class as CVE-2025-11777 (authorization scope mismatch), where a system/team-level permission check was used instead of a channel-level check.

Inconsistency proof: In the same file, getSharedChannelRemotes (line 265) correctly calls SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) before returning data. The invite/uninvite endpoints lack this check entirely.

The gap exists across the entire call chain: - API layer: api4/shared_channel.go:153, system-level only - App layer: app/shared_channel.go:113, zero authorization, passthrough - Plugin API: plugin_api.go:1504, zero authorization, any plugin can invoke - Service layer: service_api.go:113, zero authorization, will even auto-share a previously unshared channel via shareIfNotShared=true

Reproduction Steps

    1. Deploy Mattermost with shared channels enabled (ExperimentalSettings.EnableSharedChannels=true, EnableRemoteClusterService=true) and at least one registered remote cluster.
    1. As system admin, create a team and a PRIVATE channel. Post confidential data in the private channel.
    1. Create a second user (attacker). Add attacker to the team but NOT to the private channel.
    1. Verify attacker CANNOT read the private channel: GET /api/v4/channels/{private_channel_id}/posts returns HTTP 403.
    1. Grant the attacker the manage_shared_channels permission, either by assigning the SharedChannelManager role or adding the permission to their existing role.
    1. Re-authenticate as the attacker (new session to pick up permissions).
    1. Verify attacker STILL cannot read the private channel: GET /api/v4/channels/{private_channel_id}/posts returns HTTP 403.
    1. As attacker, invite a remote cluster to the private channel: POST /api/v4/remotecluster/{remote_id}/channels/{private_channel_id}/invite
    1. Observe the request does NOT return 403. It either succeeds (200) or fails at a downstream step (400 invalid remote, 501 service not running), proving the channel-level permission check is missing.
    1. If a valid remote cluster ID is used: the private channel becomes shared and its messages sync to the remote cluster, exfiltrating confidential data.
curl -X POST "https://TARGET/api/v4/remotecluster/REMOTE_CLUSTER_ID/channels/PRIVATE_CHANNEL_ID/invite" \
 -H "Authorization: Bearer SHARED_CHANNEL_MANAGER_TOKEN" \
 -H "Content-Type: application/json"

Evidence

**Docker verification on mattermost-preview:latest (v11.5.1):**

Differential test - same endpoint, same private channel (created by user2, not accessible to either test user):

Admin (has manage_shared_channels via system_admin): POST /api/v4/remotecluster/aaaabbbb…/channels/{private_channel_id}/invite => HTTP 501: “The remote cluster service is not enabled.” (PASSED auth check at line 153, reached GetRemoteClusterService at line 159)

Regular user (no manage_shared_channels): POST /api/v4/remotecluster/aaaabbbb…/channels/{private_channel_id}/invite => HTTP 403: “You do not have the appropriate permissions.” (BLOCKED at RequirePermissionToManageSharedChannels at line 153)


The 501 vs 403 differential proves the **only** authorization gate is the system-level `ManageSharedChannels` permission. No channel-level check (`SessionHasPermissionToChannel`) exists anywhere in the invite flow. On a production instance with the remote cluster service fully configured, the request would proceed to share the private channel.

**Source code proof (GitHub HEAD):**

```go
// api4/shared_channel.go:142 - VULNERABLE
func inviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.Request) {
 c.RequireRemoteId()
 c.RequireChannelId()
 c.RequirePermissionToManageSharedChannels() // Line 153: system-level ONLY
 // MISSING: c.App.SessionHasPermissionToChannel(... c.Params.ChannelId, model.PermissionReadChannel)
 ...
 c.App.InviteRemoteToChannel(c.Params.ChannelId, c.Params.RemoteId, ...) // Line 180
}

// web/context.go:837 - confirms system scope
func (c *Context) RequirePermissionToManageSharedChannels() *Context {
 if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSharedChannels) {
 c.SetPermissionError(model.PermissionManageSharedChannels)
 }
 return c
}

// api4/shared_channel.go:265 - SECURE (same file, inconsistent)
func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request) {
 if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(),
 c.Params.ChannelId, model.PermissionReadChannel); !ok {
 c.SetPermissionError(model.PermissionReadChannel) // HAS channel check
 return
 }
}

## Impact

**Confidentiality: HIGH** - An attacker with `SharedChannelManager` role (non-sysadmin) can exfiltrate all messages from any private channel on the instance by inviting their controlled remote cluster to it. This includes:
- Private channel message history (full sync)
- File attachments shared in the channel
- User metadata of channel members
- Ongoing real-time message sync

**Integrity: MEDIUM** - The attacker could also uninvite legitimate remotes from shared channels (via the uninvite endpoint with the same missing check), disrupting authorized cross-cluster collaboration.

**Additional attack surface:** The Plugin API (`PluginAPI.InviteRemoteToChannel` at plugin_api.go:1504) performs zero authorization checks. Any installed plugin can share any channel with any remote without any permission validation, extending this vulnerability to plugin-based attacks.

**Scope escalation:** The `shareIfNotShared=true` parameter (passed by the API handler) causes the service layer to auto-share a previously unshared private channel before inviting the remote - silently converting a channel that was never intended to be shared.

## Root Cause

Authorization scope mismatch. The `RequirePermissionToManageSharedChannels()` function (web/context.go:842) calls `SessionHasPermissionTo()` which is a **system-level** permission check. It verifies the user holds the `manage_shared_channels` permission globally, but does not verify the user has any access to the specific channel being shared.

The correct fix requires adding a channel-level check - `SessionHasPermissionToChannel(channelId, PermissionReadChannel)` - before proceeding with the invite, consistent with how `getSharedChannelRemotes` (line 265 in the same file) already checks channel access.

The `ManageSharedChannels` permission is defined with `PermissionScopeSystem` (model/permission.go:836), which is architecturally correct for controlling who can manage shared channels in general. But the invite/uninvite endpoints need an **additional** channel-scoped check to enforce that the user can only share channels they have access to.

## Suggested Fix

Add `SessionHasPermissionToChannel` check in both `inviteRemoteClusterToChannel` and `uninviteRemoteClusterToChannel` handlers, after the existing `RequirePermissionToManageSharedChannels` check:

```go
// api4/shared_channel.go - inviteRemoteClusterToChannel (after line 156)
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(),
 c.Params.ChannelId, model.PermissionReadChannel); !ok {
 c.SetPermissionError(model.PermissionReadChannel)
 return
}

Apply the same fix to: 1. uninviteRemoteClusterToChannel (after line 207) 2. PluginAPI.InviteRemoteToChannel (plugin_api.go:1504), add channel membership validation 3. PluginAPI.UninviteRemoteFromChannel (plugin_api.go:1508), same 4. The /share invite slash command handler (command_share.go), already implicitly scoped to current channel but should be explicitly validated


Source · github.com/zionsworking/security-research-notebook · writeups/grafana/mattermost-shared-channel-authz-bypass.md