Missing/incorrect membership restrictions in agent user access when shared with groups
Summary
The following describes a potential bug(s) related to Granting users Kubernetes access.
Background
Groups can be authorised in an agent configuration file, allowing group members access to a Kubernetes cluster via the agent. For example, this configuration grants access to all members of group-1
with developer access or higher:
user_access:
groups:
- id: path/to/group-1
Problem 1: Incorrect usage of traversal IDs
The portion of the query used to determine project access uses the following logic:
scope :for_project, ->(project) {
where("all_groups_with_membership.traversal_ids @> '{?}'", project.namespace_id)
}
This snippet is meant to check if any groups the user is a member of contain the project we are determining access for. However, because traversal IDs for a group contain the IDs for the group and all parent groups, this can return a false positive for a project that belongs to a parent group of a group the user is a member of. For example, a user that is a member of group-a/group-b/group-c
will be considered as authorised for group-a/project-1
, even though they aren't actually a member of group-a
. This bug only applies when the access check is scoped to a project, which currently happens in two contexts: the GraphQL field Project.user_access_authorized_agents
, and associating an agent with an environment.
Implications: An attacker has access (primarily read mode, with limited write access) to a subset of the cluster data through the environment page. See this comment below for the full list.
Updated implications after testing: It seems access is re-verified correctly at a later stage in the process, before actual Kubernetes resources are revealed. There is still a bug, but the scope is reduced. This now means the attacker can:
- See names of agents they shouldn't have read access to
- Associate the agent with an environment when they shouldn't be able to
This is being fixed independently in !167868 (merged), as part of #432685 (closed).
Problem 2: Determining group membership doesn't consider unconfirmed requests or blocked users
The query used to fetch groups a user is a member of uses the following code:
def groups_with_direct_membership_for(user)
::Group.joins("INNER JOIN members ON " \
"members.source_id = namespaces.id AND members.source_type = 'Namespace'")
.where(members: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. })
.select('namespaces.id AS id, members.access_level AS access_level')
end
This correctly fetches membership records associated with the user, however there are several extra conditions that should be checked, as seen in the Member.active
scope. This includes (but may not be limited to):
- Unconfirmed membership requests
- Non-active (eg. blocked, banned) users
Implications: If a group is authorised to use an agent, a user can use this authorisation top get full read/write access by requesting access to the group, without requiring the request to be approved.
The above implications need to be confirmed, the setup to achieve this is rather complex
I've confirmed that this attack is possible provided the attacker knows the ID of the agent (used to construct the Kubernetes access token). This ID is not shown publicly, but it can be read from either the agent project or another configuration that uses the agent (eg. CI access).
Steps to reproduce
Example Project
What is the current bug behavior?
What is the expected correct behavior?
Relevant logs and/or screenshots
Possible fixes
Problem 1
Problem 2
def groups_with_direct_membership_for(user)
user.groups
.merge(GroupMember.by_access_level(Gitlab::Access::DEVELOPER..))
.select('namespaces.id AS id, members.access_level AS access_level')
end