Skip to content
winfunc
Disclosure record · Mattermost

Group member IDs leaked because GetGroup bypassed view restrictions (CVE-2026-3115)

Group member ID expansion ignored the caller's ViewUsersRestrictions

MattermostCVE-2026-3115DisclosedSource record

CVSS 3.1 base score

Medium
4.3/ 10

Vector: 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-3115
Disclosed
Trace
4 steps

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

  1. Source · attacker-controlledserver/channels/api4/group.go:119

    getGroup

  2. Step 01server/channels/api4/group.go:119-128

    The API handler derives ViewUsersRestrictions for the current session and calls GetGroup with IncludeMemberIDs controlled 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)
    
  3. Step 02server/channels/app/group.go:28-39

    Before the patch, IncludeMemberIDs called GetMemberUsers(id) and appended every returned user ID. The patched code pages through GetMemberUsersPage with 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)
        }
    }
    
  4. Step 03server/channels/store/sqlstore/group_store.go:494-508

    GetMemberUsers is 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)
    }
    
  5. Step 04server/channels/store/sqlstore/group_store.go:515-523

    The patched path uses GetMemberUsersPage / GetMemberUsersSortedPage, where applyViewRestrictionsFilter limits 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)
    
  6. 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. 1

    Authenticate as a guest or otherwise restricted user.

  2. 2

    Call GET /api/v4/groups/{group_id}?include_member_ids=true for an accessible group.

  3. 3

    Observe that the response contains user IDs for all group members, not just members visible to the caller.

  4. 4

    Use the leaked IDs for user enumeration, correlation, or follow-on API probing.

Proof of concept

Reproduction5 stages
  1. 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.

  2. 02

    Target configuration

    Ensure the restricted caller can resolve the group but cannot view all users in the group under normal member-list APIs.

  3. 03

    Exploit delivery

    As the restricted user, call GET /api/v4/groups/<group_id>?include_member_ids=true with a valid session token.

  4. 04

    Expected response

    Vulnerable builds return MemberIDs containing users outside the caller's view restrictions. Fixed builds return only IDs allowed by ViewUsersRestrictions, or an empty list if no members are visible.

  5. 05

    Outcome

    The fix aligns IncludeMemberIDs with 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.

Before and after−10+13
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)    }}
View original source record

Check the upstream record for the project's current remediation status.

Your codebase

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.