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.
find_project! (lib/api/helpers.rb:183, BANG) RE-AUTHORIZES: can?(current_user, read_project_ability, project) -> unauthorized!/not_found!. SAFE.find_project (helpers.rb:165, NON-BANG) does NO can? and scopes to Project.without_deleted.not_hidden (ALL public+private). An endpoint passing the NON-BANG result to a presenter/service WITHOUT a following authorize_read_*!/authorize! is the IDOR candidate. GREP: find_project(params[...]) (no bang) + absent authorize.Model.find(params[:id]) (global finder) is NOT an IDOR if an object-level authorize! :ability, object FOLLOWS (the authorize re-binds to current_user). NEVER score a raw .find until confirming no following authorize!/can?. (Cleared 4 false-positives.)route_setting :authorization, skip_granular_token_authorization: :public_endpoint, _without_auth helpers) sharing a file with per-object DATA endpoints -> verify the DATA/download endpoint did NOT inherit the no-auth. Worked example (WALK): NuGet v2 OData present_odata_entry uses non-bang find_project + is public, but (a) the service only ECHOES the request into XML (no DB read/leak), (b) the actual /nuget/download/* endpoint authenticates + authorize_read_package! + granular :download_nuget_package. So the public OData feed is by-design; only project-existence-by-numeric-id enumeration remains (low-sev/accepted). LESSON: trace the URL the "public" endpoint hands back to its TARGET endpoint's auth before scoring; an echo-service + an authorized-download = no leak.authorize :ability (class) + authorized_find!(id:) enforces it on the PRIMARY object only. SECONDARY objects (resolved inline from another id arg) are routinely passed UN-authorized to a Service that does the per-object can?. So auditing ONLY app/graphql produces false-positives - EVERY candidate must be resolved against its Service (read app/services/.../*.rb via git show). All strong candidates (issues/move destination, packages/bulk_destroy, work_items/linked_items add, environments cluster_agent) were SERVICE-LAYER-defended (issue.can_move? admin_issue-on-target; per-package :destroy_package; :admin_work_item_link; UserAccess::Finder)..partition{ Ability.allowed?(user, :ability, obj) } and scopes the destructive op to the authorized subset. Don't flag "no class authorize on a bulk mutation" without checking the service partition.skip_*_auth:/skip_agent_auth: - verify it's NOT exposed as a mutation argument (it wasn't).Types::BaseObject.authorized? runs can?(current_user, ability, object) per returned object when the type declares authorize :read_x = the net that catches unscoped loads at the return-type boundary. GitlabSchema.find_by_gid/object_from_id are UNSCOPED (plain Model.find) -> a resolver using them self-authorizes ONLY via the returned type's authorize OR an explicit resolver Ability.allowed?.# rubocop: disable Graphql/AuthorizeTypes types whose ONLY protection is a RESOLVER-level check (not an authorized-parent-leaf, not a field_class injecting authorize). Enumerate those; for each ask "is there ANY return path to this type NOT behind that one resolver?" The bug is a SECOND caller. (GitLab thinnest: ML model-registry types + DescriptionTemplateContentResolver - both resolver-defended at HEAD = hardening/regression-test notes, NOT exploitable.)project != integration.project; design.issue_id == issue.id consistent?). ABSENCE of that re-check on a gid-loading resolver whose return type lacks authorize = the precise IDOR signature to grep.ci/job_base_field.rb forces authorize: :read_build on inherited fields -> a type can be authorize-LESS yet safe. Check the type's field_class, not just the class-level authorize line (else false-positive job types, false-negative field-injected types).BatchLoader::GraphQL.for(id) calls INSIDE type fields - look there.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.
After the scope-gate (metadata out, content/credential in), re-aimed at CONTENT/CREDENTIAL cross-privilege exposure. GitLab CE defends both comprehensively:
disable AuthorizeTypes pragma)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.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.
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.