Private channel enumeration through /mute error messages (CVE-2026-21386)
/mute exposed whether a private channel existed by returning a distinct not-member error
CVSS 3.1 base score
MediumVector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N
Record
- Project
Mattermost
- Severity
- Medium4.3
- CVE
- CVE-2026-21386
- Disclosed
- Trace
- 3 steps
Summary
Source
MuteProvider.DoCommand
server/channels/app/slashcommands/command_mute.go:48
Sink
MuteProvider.DoCommand
server/channels/app/slashcommands/command_mute.go:65
The /mute slash command accepts an optional channel handle, strips the leading ~, and looks up the named channel with Store().Channel().GetByName(channel.TeamId, channelName, true). That lookup can resolve a private channel by name even when the caller is not a member. If the channel does not exist, the handler returns api.command_mute.error; if it exists but ToggleMuteChannel fails because the caller is not a member, the handler returned api.command_mute.not_member.error. Those two responses gave authenticated users a yes/no oracle for private channel names.
The fix keeps the behavior but normalizes the not-member path to the same generic error used for nonexistent channels, and removes the now-unused translation. The original fix is PR #35099 / commit 5bb5261c72faa476558a694c23581d24b734da41; backports include #35145, #35147, #35148, and #35149.
Severity
- Attack vectorAV
- Network
- Attack complexityAC
- Low
- Privileges requiredPR
- Low
- User interactionUI
- None
- ScopeS
- Unchanged
- ConfidentialityC
- Low
- IntegrityI
- None
- AvailabilityA
- None
Metric values as published in the disclosure vector. Meters show how far each value raises exposure.
Source-to-sink trace
- Source · attacker-controlledserver/
channels/ app/ slashcommands/ command_mute.go:48 MuteProvider.DoCommand
- Step 01server/
channels/ app/ slashcommands/ command_mute.go:48-56 The slash command treats the user-supplied argument as a channel name, supporting both
~channeland bare channel names.go channelName := "" splitMessage := strings.Split(message, " ") if strings.HasPrefix(message, "~") { channelName = splitMessage[0][1:] } else { channelName = splitMessage[0] } - Step 02server/
channels/ app/ slashcommands/ command_mute.go:58-63 The handler looks up the channel by name and returns
api.command_mute.errorwhen no channel exists.go if channelName != "" && message != "" { channel, _ = a.Srv().Store().Channel().GetByName(channel.TeamId, channelName, true) if channel == nil { return &model.CommandResponse{Text: args.T("api.command_mute.error", map[string]any{"Channel": channelName}), ResponseType: model.CommandResponseTypeEphemeral} } } - Step 03server/
channels/ app/ slashcommands/ command_mute.go:65-68 Before the fix, an existing channel that the user could not mute returned a different
not_membererror. The patched code returns the generic nonexistent-channel error instead.go 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} } - Sinkserver/
channels/ app/ slashcommands/ command_mute.go:65 MuteProvider.DoCommand
Impact
Reported impact
The issue leaks the existence of private channels and their names. It does not expose channel messages, but private channel names often reveal incident, customer, project, or organizational information.
Attack surface
The authenticated /mute slash command in any team where users can submit slash commands.
Preconditions
The attacker must be authenticated and able to run slash commands in a team. They do not need to be a member of the private channels they probe.
Attack path
- 1
Run
/mute ~candidate-channelfrom a channel where slash commands are accepted. - 2
On vulnerable builds, compare the response against a definitely nonexistent channel.
- 3
api.command_mute.not_member.erroridentifies an existing channel the user is not a member of. - 4
Automate candidate names to enumerate private channel existence.
Proof of concept
- 01
Environment setup
Use a vulnerable build before PR #35099. Create a team, a normal user, and a private channel named
secret-opsthat the normal user is not a member of. - 02
Target configuration
The normal user only needs access to any channel where they can run
/mute. - 03
Exploit delivery
As the normal user, run
/mute ~secret-opsand/mute ~definitely-does-not-exist. - 04
Expected response
Vulnerable builds return different translation IDs or messages for existing-not-member versus nonexistent channels. Fixed builds return the same generic
api.command_mute.errorresponse. - 05
Outcome
The response no longer distinguishes private channel existence from nonexistent channel names.
Remediation
Guidance
Do not expose authorization failures and object-not-found failures as distinguishable messages where object existence is sensitive. Normalize the ToggleMuteChannel failure path to the same generic error used for missing channels.
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}}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}}
Check the upstream record for the project's current remediation status.
Investigate the paths that matter in your codebase.
Winfunc can trace relevant code paths, preserve supporting evidence, and prepare remediation suggestions for engineering review within an agreed scope.
