← back to lessons

GitLab IDOR/BAC source-guided audit invariants (cycle270 Phase-1, 2026-06-21)

Banked from GitLab CE (gitlab-org/gitlab@master, HEAD 8e5a8f9) Phase-1 = WALK CLEAN (3-agent fleet: GraphQL mutations + resolvers/types + REST). The authz-asymmetry recon toolkit for GitLab (and Grape/GraphQL Rails apps generally). GitLab's framework is comprehensive; these are the precise places it CAN slip + how to triage.

REST (Grape) - the bang-vs-non-bang finder invariant (the highest-signal grep)

GraphQL MUTATIONS - the two-layer authorize contract

GraphQL TYPES/RESOLVERS - the opt-out-authorize fragility primitive

CONFIRMED FINDING (the highest-signal asymmetry that hit): resolver authorization-SKIP optimization

The GitLab BAC that LIVE-CONFIRMED = LinkedItemsResolver#can_read_linked_item? (work_items/linked_items_resolver.rb:67) does return true if (work_item_type_id == && resource_parent == && confidential ==) BEFORE the Ability.allowed?(:read_work_item, item) call - an "optimize away the redundant check when properties match" shortcut. It is UNSOUND because GitLab readability ALSO depends on per-item hidden? (banned-author -> admin-only, IssuePolicy:94) and per-item assignee_or_author? (confidential access, IssuePolicy:82) - NOT captured by (type, parent, confidential). So a same-(type,parent,confidential) sibling can be UN-readable -> the node is wrongly admitted, leaking the un-authorized wrapper type's SCALAR fields (state, link_type, timestamps; the nested authorized WorkItemType still nulls title/body). Live-confirmed: attacker DENIED direct read of a confidential WI (workItem->nil) but reads its STATE+link via a readable source's linkedItems. THE GENERALIZABLE GREP (highest-signal authz-asymmetry pattern): a resolver/policy that return true/short-circuits BEFORE an Ability.allowed?/can? call, based on ATTRIBUTE-EQUALITY with a known-readable sibling. "Same project + same type + same confidential-flag => same readability" is FALSE in GitLab (hidden? + assignee_or_author? break it). Any "optimize away the per-object authz when attributes match" comment is the bug. Confirmed > the missing-authorize class (which GitLab's framework catches) - the SKIP-optimization is where a real BAC survives the framework.

CONTENT/CREDENTIAL exposure pass (the IN-SCOPE re-aim) = WALK CLEAN (comprehensive framework)

After the scope-gate (metadata out, content/credential in), re-aimed at CONTENT/CREDENTIAL cross-privilege exposure. GitLab CE defends both comprehensively:

Credentials: 4 controls, one always applies (audit the COMPENSATING control, not the disable AuthorizeTypes pragma)

  1. type-level authorize :admin_x (PipelineTrigger :manage_trigger, AlertIntegration :admin_operations, CiRunner-parent, ClusterAgent :read_cluster_agent); 2. parent-FIELD authorize: for disable-AuthorizeTypes types (stored CI vars -> :admin_cicd_variables); 3. type-level VALUE REDACTION def value re-checking the ability when the parent field is weak (PipelineManualVariable.value -> :read_pipeline_variable because Pipeline is only :read_pipeline); 4. FIELD OMISSION (InheritedCiVariable/ClusterAgentToken/PAT/ProjectHook never expose the raw secret). - DISCRIMINATOR (don't false-positive): a value from Ci::VariableValue.new(obj).evaluate/obj.value on a Ci::*Variable model = REAL secret (needs a guard); a value from ListConfigVariablesService/YamlProcessor.root_variables_with_prefill_data = .gitlab-ci.yml repo YAML (already repo-readable, NOT a cross-priv secret). Trace the value method to its model before claiming a credential leak. - masked != redacted: Ci::VariableValue.evaluate only nils hidden? vars; masked vars still return raw over GraphQL (masking is display-only) -> the parent-field authz is the SOLE control for masked stored secrets.

Content: skip_type_authorization is ALWAYS compensated by a finder/resolver filter

The exploitable content-leak signature = a # rubocop:disable Graphql/AuthorizeTypes type whose OWN scalar field is CONTENT (description/body/rawBlob directly, NOT a separately-re-authorized nested type like linkedItems' WorkItemType) AND admitted by a resolver whose authz is skipped/optimized WITHOUT compensation. NONE at HEAD: every skip_type_authorization:[X] (notes-widget discussions, MR discussions, hierarchy children/ancestors) is compensated (NotesFinder#redact_internal for internal notes; HierarchyResolver#authorized_work_items per-node Ability.allowed?). Repo blob double-gated (RepositoryType :read_code + BlobsResolver authorize! container). Snippet content via SnippetType :read_snippet.

Rule-38 REGRESSION WATCHES (the highest-value forward checks - re-diff these each GitLab security release)

  1. InheritedCiVariableType - deliberately OMITS value though GroupVariablesFinder loads parent-group secrets; if a future commit adds value/VariableInterface -> cross-priv GROUP-SECRET leak to project Maintainers. 2. HierarchyResolver#authorized_work_items + NotesFinder#redact_internal - if a refactor drops the per-node filter while skip_type_authorization stays -> confidential description/note-BODY leak (IN-SCOPE content). These are the concrete incomplete-fix/regression targets for the next GitLab cycle.

NET: GitLab CE in-scope (content/credential) BAC = comprehensively defended (the framework + the 4 credential controls + compensating-finder idiom). The one real bug (linkedItems) is metadata-only = out-of-scope (held). Residual in-scope EV: EE surface (blocked by gitlab.com load), the incomplete-fix-CVE vein (diff a CONTENT-disclosure security release), the fuzzer-arm.

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