Private Channel Enumeration via /mute Slash Command (CVE-2026-21386)
Summary
The /mute slash command returned different error messages for nonexistent channels versus private channels the user was not a member of, allowing authenticated users to enumerate private channels
The MuteProvider.DoCommand handler allows a user to specify ~channel-handle to toggle the mute state of any channel. Instead of using an authorization-aware helper, it directly calls Store().Channel().GetByName() which returns private channel metadata without verifying membership. The command then returns two different localized errors: api.command_mute.error when a channel truly does not exist, and api.command_mute.not_member.error when ToggleMuteChannel fails because the caller is not a member. This lets any authenticated team member probe arbitrary handles (/mute ~secret-ops) to distinguish nonexistent channels from private channels they are not authorized to know about, leaking channel existence and names.
CVSS
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N
Source → Sink
Source
server/channels/app/slashcommands/command_mute.go
Line 48 · (*MuteProvider).DoCommand
Sink
server/channels/app/slashcommands/command_mute.go
Line 65 · (*MuteProvider).DoCommand
Attack surface
Any authenticated user in a team can issue slash commands. The /mute command with ~channelname arguments is the attack vector.
Preconditions
Attacker must be an authenticated user in the team but does not need any special role. Only requires knowledge of the team ID and membership in some channel to run slash commands.
Impact
Attackers can enumerate all private channel names within a team, revealing sensitive project or incident names that should be hidden. While no content is exposed, the information leakage can facilitate targeted social engineering and reconnaissance. The attack is low complexity and can be automated with no throttling.
Exploit path
How the issue forms.
User-supplied channelName is looked up via the raw store, which returns private channels without membership checks.
channel, _ = a.Srv().Store().Channel().GetByName(channel.TeamId, channelName, true)Nonexistent channels return a distinct error message.
if channel == nil { return ... api.command_mute.error }When the channel exists but user is not a member, a different error string is returned, enabling enumeration.
channelMember, err := a.ToggleMuteChannel(...); if err != nil { return ... api.command_mute.not_member.error }Proof of concept
Reproduction.
Environment
Requirements: Ubuntu 22.04 or macOS 14.
Install dependencies:
sudo apt update && sudo apt install -y make git go curl
Clone and build Mattermost:
git clone https://github.com/mattermost/mattermost.git
cd mattermost
make build-server
cd server
./bin/mattermost
Server listens on http://localhost:8065.
Configuration
- Create an admin account and a test team via the web UI.
- Sign in as an ordinary member (
[email protected]). - Ensure a private channel named
secret-opsexists in the team where the user is not a member.
Delivery
Use the slash command input box:
- In any channel, run
/mute ~secret-ops. - Observe the response
api.command_mute.not_member.error. - Run
/mute ~doesnotexistand observeapi.command_mute.error.
Alternatively, using the REST API:
curl -X POST http://localhost:8065/api/v4/commands/execute \
-H 'Authorization: Bearer <user-token>' \
-H 'Content-Type: application/json' \
-d '{
"channel_id": "<current-channel-id>",
"team_id": "<team-id>",
"command": "/mute ~secret-ops"
}'
Log responses:
api.command_mute.not_member.error→ private channel existsapi.command_mute.error→ channel absent
Outcome
Attacker enumerates names of private channels without authorization, leaking sensitive project names.
Remediation
The fix.
Return the same error message in both cases to prevent private channel discovery. The fix changes the ToggleMuteChannel error path to return api.command_mute.error (the same generic error used for nonexistent channels) instead of the distinct api.command_mute.not_member.error, and removes the now-unused translation key.
Before
channelMember, err := a.ToggleMuteChannel(rctx, channel.Id, args.UserId)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_mute.not_member.error", map[string]any{"Channel": channelName}), ResponseType: model.CommandResponseTypeEphemeral}
}After
channelMember, err := a.ToggleMuteChannel(rctx, channel.Id, args.UserId)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_mute.error", map[string]any{"Channel": channelName}), ResponseType: model.CommandResponseTypeEphemeral}
}