← back to lessons

GitLab CE latest - FULL /api/v4 surface differential IDOR/BAC: WALK CLEAN at scale (1179 endpoints)

Date: 2026-06-23 Target: GitLab CE :latest, self-hosted docker (gl-fuzz, :8929), disposable Tool: fuzzer-arm v0.3 (full-surface adapter targets/gitlab_full.py + run_gitlab_full.py) Cash-gate: GitLab HackerOne program ACTIVE + PAYING (policy updated 2026-05-05, avg bounty $5,667); product source code in-scope at highest reward tier; GitLab explicitly recommends self-managed/local testing. IDOR/BAC = standard in-scope. Self-hosted differential is in-ROE + cash-eligible.

What changed vs the v0 GitLab run (the reason to re-hunt)

v0 tested 12 CURATED endpoints (looked clean -> "is it clean only because we barely looked?"). v0.3 enumerated the ENTIRE surface from the live Grape route table and tested ~100x more: - 1419 REST routes dumped via gitlab-rails runner over API::API.routes (641 GET, 332 POST, 272 PUT, 155 DELETE, 19 PATCH; 550 GET-with-id-param) - + 9 GraphQL GID/fullPath cross-tenant probes (introspection-independent) - 1179 endpoints actually TESTED (508 read pass + 671 write pass), rest unfillable.

Result: GENUINELY CLEAN

The "old run only tried 12" hypothesis is REFUTED: clean at 1179 too. GitLab CE's per-object authorization (declarative_policy + find_project!/authorize!) holds across the full surface.

NEW: GitLab full-surface enumeration recipe (banked capability)

Grape serves no usable OpenAPI -> dump the route table from the running container: docker exec <c> gitlab-rails runner 'API::API.routes.each{|r| puts "#{r.request_method}\t#{r.path}"}' then normalize Grape templates to {param}: strip (.:format), :version->v4, and name bare :id after the preceding literal segment (/projects/:id -> {project_id}, /groups/:id -> {group_id}). This converts a "no-spec" target into a fully-enumerable one. Reusable for any Grape/Rails-mounted API.

FP-DISCRIMINATORS banked (what flagged but is BENIGN - tune future triage)

  1. caller-scoped identical-empty-body -> IDOR-weak, NOT IDOR. /runners, /todos, /user/keys, /user/gpg_keys, /user/status, /user/activities, /user/applications: both A and B get their OWN (often empty) list -> identical body, anon blocked, NO marker. Benign. The no-marker is the tell.
  2. global utility behind auth -> IDOR-weak, benign. /version, /metadata, /namespaces/{id}/exists, /snippets/public, /users (list). Auth-gated but same answer for everyone; no private data.
  3. social-graph reads -> IDOR-weak, benign. /users/{id}/followers, /following (any logged-in user may read). Same class as the Gitea NEEDS-HUMAN bucket (cross-target consistent).
  4. by-design cross-user WRITE -> false write-BAC. POST /users/{id}/follow + /unfollow returned B=201; that is an INTENDED cross-user social action, not BAC. The status-based write heuristic cannot tell "B mutated A's PRIVATE resource" from "B did an intended cross-user action" -> these need a by-design allowlist (follow/unfollow/star/etc.) before promotion.
  5. /self literal in path -> false write-BAC. DELETE /personal_access_tokens/self returned B=204; self resolves to the CALLER's own token (bob deleted bob's), caller-scoped self-service, not cross-tenant. Any /self, /me, /current literal segment is caller-scoped -> exclude from BAC.

ACTION (next engine tick, <=24h per Impl Rule): add an FP-allowlist to differ.triage for follow/unfollow/star + /self|/me|/current segments so the write pass auto-demotes these to benign instead of FLAGGED. Until then they are correctly surfaced (not silently dropped) and human-dismissed.

Reflection (per Standing Reflection Mandate)

Doctrine echo: WALK CLEAN still banks lessons. The capability (full-surface Grape enumeration + 1179-endpoint low-noise differential) is the asset; point it next at a fresher/less-audited multi-tenant API. Per directive, GitLab genuinely-clean -> rotate to Mattermost CORE /api/v4 (Bugcrowd active+paying, cash-gate already confirmed, core API never fuzzed - only plugins). NO auto-submit anywhere.

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