← back to lessons

GitLab hierarchy-traversal authz: the entitlement-implication invariant (cycle270 Phase-3b, CVE-2026-1080 incomplete-fix vein)

Banked from the CVE-2026-1080 (GitLab EE iterations API descendant-group disclosure, CWE-639, CVSS 4.3, fixed 18.8.4/18.7.4/18.6.6) incomplete-fix drill. Prime target + the milestone sibling both = WALK CLEAN; the value is the methodology.

1. THE DEEP INVARIANT (reusable across all GitLab hierarchy finders)

A finder that traverses a namespace hierarchy (parent -> self_and_descendants / self_and_ancestors / self_and_hierarchy) and authorizes only the PARENT needs a per-group visibility filter (accessible_to_user / public_or_visible_to_user) on the expanded group set IFF the ENTRY authz does NOT imply entitlement to the TRAVERSED set: - group -> DESCENDANTS: parent :read_group/:read_iteration does NOT imply membership in descendant groups -> MUST filter. This is the leak axis. CVE-2026-1080 = the iterations finder traversed self_and_descendants/self_and_hierarchy, authorized only the parent, and returned of_groups(all) UNFILTERED -> private descendant-group iterations leaked. FIX = groups.accessible_to_user(current_user) in IterationsFinder#by_groups. - project -> ANCESTORS: project membership IMPLIES the descendant_project_member role on every ancestor group -> which grants read_group -> which enables read_milestone/read_iteration. Entitlement IS implied -> the filter is CORRECTLY OMITTED. An "unfiltered ancestor traversal" on a project endpoint is a FALSE POSITIVE.

2. THE FALSE-POSITIVE GUARD (trace this chain before scoring "missing visibility filter")

GitLab GroupPolicy: rule { has_projects }.enable(*Authz::Role.get(:descendant_project_member).direct_permissions(:group)) (a member of ANY project in the hierarchy gets the descendant_project_member group perms); config/authz/roles/descendant_project_member.yml -> group.raw_permissions: [read_group, upload_file]; rule { can?(:read_group) }.enable :read_milestone. So a project member is by-design entitled to ancestor-group planning resources. Milestones exploit this: group_project_params.rb filters the GROUP path with public_or_visible_to_user but deliberately OMITS it on the PROJECT->ancestors path (parent.group.self_and_ancestors) - and that omission is SOUND. Before flagging a missing filter: trace entry-authz -> descendant_project_member/membership -> the resource read ability. If entry implies entitlement = by-design.

3. REST-entity-vs-GraphQL-type authz asymmetry (where finder-level info-leaks surface)

A GraphQL BaseObject type can carry authorize :ability = a PER-NODE backstop re-checked on every resolved object (e.g. IterationType authorize :read_iteration). A REST Grape Entities::X CANNOT - REST relies SOLELY on the endpoint authorize! + the finder's row-set. So a finder-level info-disclosure bug manifests on REST FIRST/ONLY; GraphQL is often saved by the type backstop. When diffing any GitLab "info disclosure via finder" CVE, audit the REST entity path, not just GraphQL. (CVE-2026-1080's vector was REST Entities::Iteration; the GraphQL IterationsResolver was double-protected by IterationType's type-authz.)

4. SHARED-FINDER = SWEEPER-FIX predictor (confirmed on GitLab; same as the Forgejo/Gitea port-lag calibration)

When a fix lands in a shared finder/data-access object that ALL consumers call (IterationsFinder is called by REST project + REST group + GraphQL resolver), the incomplete-fix sibling must be a surface that does NOT use that finder. Enumerate finder callers + independent data paths; if all covered -> WALK. Here every iteration/cadence-data surface either used the fixed finder or had independent authz (cadences = per-group :read_iteration_cadence + no descendants; timebox report = :read_issue/:read_group scope; IterationType = per-node). No divergence -> WALK (the opposite of the cycle270 linkedItems case, where the divergent resolver was the lone unguarded one).

5. REGRESSION WATCH (the concrete next CVE-2026-1080-class target)

A NEW (or unaudited) hierarchy-expanding finder offering include_descendants (the entitlement-NOT-implied direction) whose caller builds group_ids via self_and_descendants/self_and_hierarchy WITHOUT a public_or_visible_to_user/accessible_to_user filter = the next bug. Audit list: epics (EpicsFinder), boards, labels (LabelsFinder), releases group-finders, work-item-hierarchy, and any new group-planning API with a descendants toggle. Diff each for the filter on the descendant path.

Related: [[feedback_cross_fork_portlag_primitive]], [[2026-06-21-cycle270-gitlab-authz-asymmetry-invariants]], [[2026-06-21-scope-gate-impact-must-be-in-program-scope]].

6. VALIDATED BIDIRECTIONALLY + the membership-vs-feature-toggle discriminator (cycle270 Phase-3c regression sweep)

Drained the regression watch (epics/boards/labels/releases). The entitlement-implication invariant now holds in BOTH traversal directions, empirically (self-host rails probes): - project -> ANCESTORS (milestones): entry = project membership => descendant_project_member => read_group => ancestor milestone read. Filter correctly omitted. - group -> DESCENDANT PROJECTS (GroupReleasesFinder, REST GET /groups/:id/releases, no per-project filter, no REST backstop): entry = group :read_release REQUIRES MEMBERSHIP (FALSE for anon/non-member even on a PUBLIC group) => inherited membership cascades down => per-project :read_release granted on every descendant project. Confirmed: anon/non-member group-read=false (403); guest/reporter member group-read=true AND project-read=true. Filter correctly omitted = by-design-safe. - group -> DESCENDANT GROUPS (iterations CVE-2026-1080): entry = parent :read_iteration does NOT imply descendant-group membership. Filter REQUIRED. The one true bug direction. - EpicsFinder filters via Group.groups_user_can(:read_epic); LabelsFinder seeds from readable ProjectsFinder then narrows. Both defended.

THE DISCRIMINATOR (anti-false-positive, load-bearing): membership/visibility boundary (REAL) vs feature-toggle (WEAK/disputed)

A missing per-project/per-group filter on a hierarchy finder is a REAL finding ONLY if the unfiltered traversal reaches a resource the actor is denied by a MEMBERSHIP or VISIBILITY (private/members-only) boundary. If the only thing denying per-resource access is a FEATURE TOGGLE (*_access_level: DISABLED) - where the actor already has membership/visibility and only the sub-feature is off - the "leak" is a weak, likely-by-design/won't-fix boundary, NOT a confident in-scope candidate. (GroupReleasesFinder cracks ONLY on releases-feature-DISABLED-with-pre-existing-release: empirically a :read_release=false bypass, but the guest member already has group+project access -> held LOW/disputed, not submitted.) Before scoring: classify the denying control - membership/visibility = real; feature-toggle on an already-authorized member = weak.

Generated 2026-07-02 13:15:04 UTC | auto-sync /15min