Group member IDs leaked because GetGroup bypassed view restrictions (CVE-2026-3115)
Group member ID expansion ignored the caller's ViewUsersRestrictions
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
Summary
Source
getGroup
server/channels/api4/group.go:119
Sink
App.GetGroup
server/channels/app/group.go:28
The GET /api/v4/groups/{group_id} handler computed ViewUsersRestrictions for the session user and passed those restrictions into App.GetGroup. GetGroup correctly applied restrictions when returning IncludeMemberCount, but when IncludeMemberIDs was requested it called Store().Group().GetMemberUsers(id), a raw unpaginated store method with no restrictions parameter. Guests or restricted users could therefore request a group with include_member_ids=true and receive member user IDs outside the teams or channels they were allowed to see.
The fix changes the member-ID expansion path to page through GetMemberUsersPage(id, page, perPage, viewRestrictions), which applies applyViewRestrictionsFilter in the SQL store. The original fix is PR #35172 / commit a06d5065e709e26baed531a528d4b9950f26e3ea; backports include #35209, #35277, and #35333.
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/ api4/ group.go:119 getGroup
- Step 01server/
channels/ api4/ group.go:119-128 The API handler derives
ViewUsersRestrictionsfor the current session and callsGetGroupwithIncludeMemberIDscontrolled by the query string.go restrictions, appErr := c.App.GetViewUsersRestrictions(c.AppContext, c.AppContext.Session().UserId) if appErr != nil { c.Err = appErr return } group, appErr := c.App.GetGroup(c.Params.GroupId, &model.GetGroupOpts{ IncludeMemberCount: c.Params.IncludeMemberCount, IncludeMemberIDs: c.Params.IncludeMemberIDs, }, restrictions) - Step 02server/
channels/ app/ group.go:28-39 Before the patch,
IncludeMemberIDscalledGetMemberUsers(id)and appended every returned user ID. The patched code pages throughGetMemberUsersPagewith the same view restrictions.go if opts != nil && opts.IncludeMemberIDs { perPage := 100 users, err := utils.Pager(func(page int) ([]*model.User, error) { return a.Srv().Store().Group().GetMemberUsersPage(id, page, perPage, viewRestrictions) }, perPage) if err != nil { return nil, model.NewAppError("GetGroup", "app.member_count", nil, "", http.StatusInternalServerError).Wrap(err) } for _, user := range users { group.MemberIDs = append(group.MemberIDs, user.Id) } } - Step 03server/
channels/ store/ sqlstore/ group_store.go:494-508 GetMemberUsersis the unrestricted store method that previously fed the response. It filters only deleted rows and group ID, not caller visibility.go builder := s.groupMemberUsersSelectQuery. Where(sq.Eq{ "GroupMembers.DeleteAt": 0, "Users.DeleteAt": 0, "GroupMembers.GroupId": groupID, }) if err := s.GetReplica().SelectBuilder(&groupMembers, builder); err != nil { return nil, errors.Wrapf(err, "failed to find member Users for Group with id=%s", groupID) } - Step 04server/
channels/ store/ sqlstore/ group_store.go:515-523 The patched path uses
GetMemberUsersPage/GetMemberUsersSortedPage, whereapplyViewRestrictionsFilterlimits results to users visible through the caller's teams or channels.go userQuery := s.groupMemberUsersSelectQuery. Where(sq.Eq{"GroupMembers.DeleteAt": 0}). Where(sq.Eq{"Users.DeleteAt": 0}). Where(sq.Eq{"GroupMembers.GroupId": groupID}) userQuery = applyViewRestrictionsFilter(userQuery, viewRestrictions, true) - Sinkserver/
channels/ app/ group.go:28 App.GetGroup
Impact
Reported impact
The vulnerability leaks membership information across Mattermost's user visibility boundary. It does not expose message content, but it reveals user IDs and group membership relationships that restricted users should not be able to enumerate.
Attack surface
The authenticated groups API when the caller can access a group object and request include_member_ids=true, especially guest or restricted-user deployments where ViewUsersRestrictions are expected to hide unrelated users.
Preconditions
The attacker must be authenticated and able to call the groups API for a referenceable group. The target deployment must have users outside the attacker's visible teams or channels who are members of that group.
Attack path
- 1
Authenticate as a guest or otherwise restricted user.
- 2
Call
GET /api/v4/groups/{group_id}?include_member_ids=truefor an accessible group. - 3
Observe that the response contains user IDs for all group members, not just members visible to the caller.
- 4
Use the leaked IDs for user enumeration, correlation, or follow-on API probing.
Proof of concept
- 01
Environment setup
Use a vulnerable build before PR #35172 with guest accounts or restricted user visibility enabled. Create a group with members spread across a channel/team the guest can see and users the guest cannot see.
- 02
Target configuration
Ensure the restricted caller can resolve the group but cannot view all users in the group under normal member-list APIs.
- 03
Exploit delivery
As the restricted user, call
GET /api/v4/groups/<group_id>?include_member_ids=truewith a valid session token. - 04
Expected response
Vulnerable builds return
MemberIDscontaining users outside the caller's view restrictions. Fixed builds return only IDs allowed byViewUsersRestrictions, or an empty list if no members are visible. - 05
Outcome
The fix aligns
IncludeMemberIDswith the already-restricted member-count path.
Remediation
Guidance
Never expand user IDs for a restricted caller through unrestricted store methods. Use the paginated store APIs that accept ViewUsersRestrictions, and apply the same restriction model to counts and member-ID lists.
if opts != nil && opts.IncludeMemberIDs {users, err := a.Srv().Store().Group().GetMemberUsers(id)if err != nil {return nil, model.NewAppError("GetGroup", "app.member_count", nil, "", http.StatusInternalServerError).Wrap(err)}for _, user := range users {group.MemberIDs = append(group.MemberIDs, user.Id)}}if opts != nil && opts.IncludeMemberIDs { perPage := 100 users, err := utils.Pager(func(page int) ([]*model.User, error) { return a.Srv().Store().Group().GetMemberUsersPage(id, page, perPage, viewRestrictions) }, perPage) if err != nil { return nil, model.NewAppError("GetGroup", "app.member_count", nil, "", http.StatusInternalServerError).Wrap(err) } for _, user := range users { group.MemberIDs = append(group.MemberIDs, user.Id) }}
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.
