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
-
- Deploy Mattermost with shared channels enabled (ExperimentalSettings.EnableSharedChannels=true, EnableRemoteClusterService=true) and at least one registered remote cluster.
-
- As system admin, create a team and a PRIVATE channel. Post confidential data in the private channel.
-
- Create a second user (attacker). Add attacker to the team but NOT to the private channel.
-
- Verify attacker CANNOT read the private channel: GET /api/v4/channels/{private_channel_id}/posts returns HTTP 403.
-
- Grant the attacker the manage_shared_channels permission, either by assigning the SharedChannelManager role or adding the permission to their existing role.
-
- Re-authenticate as the attacker (new session to pick up permissions).
-
- Verify attacker STILL cannot read the private channel: GET /api/v4/channels/{private_channel_id}/posts returns HTTP 403.
-
- As attacker, invite a remote cluster to the private channel: POST /api/v4/remotecluster/{remote_id}/channels/{private_channel_id}/invite
-
- 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.
-
- 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