# Variant RCA Report — CVE-2026-85706 (GitLab Repository Commits API unauthenticated file read)

## Summary

The parent CVE is an unauthenticated arbitrary file read reached by appending a
`.json` format suffix to `POST /api/v4/projects/:id/repository/commits`, which
defeats GitLab Workhorse's anchored route regex
(`^/api/v4/projects/[^/]+/repository/commits\z`) so Workhorse proxies the raw
request to Rails, where the vulnerable Grape endpoint (only
`require_gitlab_workhorse!`, no `authenticate!`) feeds the attacker-controlled
`file.path` request parameter straight into `File.exist?` / `File.read` via
`API::Helpers::CommitsBodyUploaderHelper#file_params_from_body_upload`.

This variant analysis audited **every** Workhorse-accelerated route (regexes
extracted from the shipped `gitlab-workhorse` Go binaries of both the vulnerable
`19.3.1-ce.0` and fixed `19.3.2-ce.0` omnibus images — they are **identical**,
i.e. Workhorse was not changed by the fix) against every Rails handler that
trusts the Workhorse body-upload finalization contract.

**Runtime outcome (confirmed live on the real omnibus stack, twice):**

1. **A distinct ALTERNATE TRIGGER is confirmed on the vulnerable 19.3.1 build.**
   The sibling **Repository Files API** endpoints
   `POST`/`PUT /api/v4/projects/:id/repository/files/:file_path` in
   `lib/api/files.rb` **also lack `authenticate!`** in 19.3.1 and call the
   identical `file_params_from_body_upload` sink. Their Workhorse regex
   (`^/api/v4/projects/[^/]+/repository/files/[^/]+\z`) ends in a wildcard
   segment that a format suffix cannot defeat, but a **trailing slash**
   (`.../repository/files/vfile.txt/`) both (a) makes the clean path end in `/`
   so the `\z`-anchored regex does not match (Workhorse does not classify /
   does not intercept), and (b) still routes to the create/update-file Grape
   endpoints. The unauthenticated request
   `POST /api/v4/projects/1/repository/files/vfile.txt/?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=/tmp/canary_85706v.txt`
   returned the full canary content in the response body:
   `{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_VCANARY_VC1_d41d8cd98f00_PCTBYTE_%zz_END)"}`
   (same for `PUT`). This is a materially different entry point (different API
   endpoint, different HTTP method surface, different regex-defeat encoding)
   into the same sink and the same root cause.
2. A second encoding of the parent bypass was also confirmed: a **trailing
   slash** on the commits route (`POST /api/v4/projects/1/repository/commits/`)
   reproduces the same canary echo as the `.json` suffix (same endpoint, so this
   is an encoding variant, recorded as corroborating evidence).
3. **NO BYPASS of the fixed 19.3.2 build exists for any tested path.** All
   matrix entries against `19.3.2-ce.0` (`.json`-suffixed and trailing-slash
   commits, trailing-slash/plain/`.json` files POST/PUT, and both
   `/authorize.json` probes) return `401 Unauthorized` with no file-content
   echo and no existence oracle, because `authenticate!` now runs before
   `file_params_from_body_upload` in all three endpoints and in
   `workhorse_authorize_commits_body_upload!`, and the sink only accepts
   JWT-middleware-finalized `::UploadedFile` objects.

## Fix Coverage / Assumptions

- **Invariant the fix relies on**: after the fix, using the commits/files
  body-upload endpoints requires (1) passing `authenticate!` first, and
  (2) receiving a middleware-finalized `::UploadedFile`. `Gitlab::Middleware::
  Multipart` constructs `::UploadedFile` **only** from a JWT signed with the
  Workhorse secret (`Gitlab-Workhorse-Multipart-Fields` header /
  `upload.gitlab-workhorse-upload` param), and `UploadedFile.from_params`
  `File.realpath`s the path and rejects anything outside the allowed upload
  directories. Client-supplied multipart produces
  `Rack::Multipart::UploadedFile` / `ActionDispatch::Http::UploadedFile`,
  which are not `::UploadedFile` → `400 file is invalid`. Verified sound in the
  shipped 19.3.2 source.
- **What the fix covers**: `lib/api/commits.rb` `post ':id/repository/commits'`,
  `lib/api/files.rb` `post`/`put ':id/repository/files/:file_path'`
  (`authenticate!` added), and
  `lib/api/helpers/commits_body_uploader_helper.rb` (authenticate in
  `workhorse_authorize_commits_body_upload!`; sink restricted to
  `::UploadedFile`; no `e.message` echo). Runtime-verified: all covered paths
  return 401 on 19.3.2.
- **What the fix does NOT cover** (residual, defense-in-depth):
  - Workhorse route classification itself is unchanged (regexes byte-identical
    between the two binaries); every `\z`-anchored route remains
    suffix/trailing-slash defeatable. The defense lives only in the Rails
    handlers.
  - No `::API::NO_FORMAT_SUFFIX_REQUIREMENT` (defined in both versions and used
    by packages/releases routes) was applied to the commits route —
    `/repository/commits.json` and `/repository/commits/` still **route** in
    19.3.2; only `authenticate!` blocks them.
  - A CI-level invariant that any endpoint under a Workhorse-accelerated route
    must call `authenticate!`/`authenticate_job!` is absent; a future endpoint
    with only `require_gitlab_workhorse!` would reintroduce the bug class.

## Variant / Alternate Trigger

**Confirmed alternate trigger (vulnerable 19.3.1 only):**

- Entry point: `POST` (and `PUT`)
  `http://<target>/api/v4/projects/<id>/repository/files/<any-file-path-segment>/`
  (trailing slash required) with query
  `file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=<arbitrary filesystem path>`,
  header `Content-Type: application/x-www-form-urlencoded`, empty body,
  unauthenticated.
- Code path: nginx → gitlab-workhorse (clean path
  `/api/v4/projects/1/repository/files/vfile.txt/` does **not** match
  `^/api/v4/projects/[^/]+/repository/files/[^/]+\z` → raw proxy with signed
  internal header) → Rails/Grape routes the trailing-slash path to
  `API::Files post/put ':id/repository/files/:file_path'`
  (lib/api/files.rb, 19.3.1 lines 362/407: `require_gitlab_workhorse!` only,
  **no `authenticate!`**) → `CommitsBodyUploaderHelper#file_params_from_body_upload`
  → `File.exist?`/`File.read(params['file.path'])` →
  `Rack::Utils.parse_nested_query(file content)` →
  `InvalidParameterError` message (embedding the file content) echoed in the
  400 response.
- Corroborating encoding variant: `POST /api/v4/projects/1/repository/commits/`
  (trailing slash instead of `.json`) — same endpoint as the parent claim, so
  not counted as a distinct trigger.

**Tested and ruled out (see `patch_analysis.md` coverage matrix):** every other
Workhorse-accelerated route (`projects/:id/uploads`, wikis attachments, alert
metric images, jobs artifacts/sbom scans, project/group import, placeholder
reassignments, terraform state, all packages routes, user/group/organization
avatar upload, web `/uploads/*` and `/import/*` routes) enforces its own
authentication in both 19.3.1 and 19.3.2; terraform/packages regexes are
prefix-based and not suffix/trailing-slash defeatable. `GET /api/v4/geo/proxy`
is Workhorse-only without `authenticate!` but discloses only Geo proxy
configuration (EE-only, no file access) — a different sink, not the same root
cause. Controls proving the mechanism: the plain files path
(`/repository/files/vfile.txt`, no trailing slash) is intercepted by Workhorse
(pre-authorized via the then-unauthenticated authorize endpoint, body finalized
to an empty tempfile) and returns `400 branch is required` **without** any file
read — the attacker's `file.path` param is overridden by workhorse's finalized
multipart params; only the trailing-slash (unintercepted) form reaches the
vulnerable raw-param path.

## Impact

- **Package/component affected**: GitLab CE/EE omnibus — `lib/api/files.rb`
  (Repository Files API), `lib/api/commits.rb`, `lib/api/helpers/
  commits_body_uploader_helper.rb`, gitlab-workhorse route classification.
- **Affected versions (as tested)**:
  - vulnerable: `gitlab/gitlab-ce:19.3.1-ce.0`, image digest
    `sha256:f63df4c43029fe91db370609c0b40a1e3585cebd06e3e9637d93a9a3030eb86e`,
    gitlab-rails build revision `668508315ee5b5a59aa018424f741c27e81bafe1`
    (from `/opt/gitlab/version-manifest.txt`).
  - fixed: `gitlab/gitlab-ce:19.3.2-ce.0`, image digest
    `sha256:05453dd1d9aba27c2c487613141596868409b4d03247647f7d66cb0b36f321b8`,
    gitlab-rails build revision `34042bf7d00ca54c5e04079df6cdc6151485fd46`.
  - Per the advisory: all 18.7 before 19.1.8, 19.2 before 19.2.6, 19.3 before
    19.3.2.
- **Risk level**: Critical — the alternate trigger inherits the parent CVE's
  full CVSS 3.1 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) impact class on
  unpatched instances: unauthenticated arbitrary file read as the `git` service
  account (`/etc/gitlab/gitlab-secrets.json`, DB credentials, Gitaly/Praefect
  tokens, private repository content).

## Impact Parity

- **Disclosed/claimed maximum impact** (parent CVE): unauthenticated arbitrary
  file read of any file readable by the GitLab service account.
- **Reproduced impact from this variant run** (on 19.3.1):
  - Full attacker-chosen file **content echo** through the *Files API* alternate
    trigger: complete canary content reflected in the 400 response body for
    both `POST` and `PUT` (see `vuln_t3_files_post_slash.txt` /
    `vuln_t4_files_put_slash.txt`).
  - Full content echo also via the trailing-slash commits encoding
    (`vuln_t2_commits_slash.txt`).
  - Controls: plain-path files request (Workhorse-intercepted) does **not** read
    the attacker path (`400 branch is required`, no echo) — the echo depends on
    the Workhorse regex being defeated, exactly as in the parent mechanism.
- **Parity**: `full` for the claimed info-leak primitive, via a distinct entry
  point on the vulnerable version; `none` on the fixed version (no bypass).
- **Not demonstrated**: post-read weaponization (forging signed tokens from
  `gitlab-secrets.json`); out of scope for the filed file-read claim.

## Root Cause

Same underlying root cause as the parent CVE: **Workhorse classifies
body-upload-accelerated API routes with `\z`-anchored regexes on the clean
(escaped) request path, while Rails/Grape routes the same request after
stripping an optional `(.:format)` suffix (and tolerating trailing slashes) —
the two layers disagree about the request's identity.** An endpoint that relies
solely on `require_gitlab_workhorse!` (which only asserts "the request
transited Workhorse", true for all omnibus traffic) and on Workhorse having
finalized the upload is exploitable whenever Workhorse's regex fails to match
but Rails still routes. In 19.3.1 the commits **and files** body-upload
endpoints both lacked `authenticate!` and both read `params['file.path']` as a
filesystem path; the parent PoC used `.json` on the commits route, this
analysis shows a trailing slash defeats the same regexes and additionally
defeats the files-route wildcard tail. The v19.3.2 fix (Rails-side only;
Workhorse binary unchanged) adds `authenticate!` to all three endpoints plus
the authorize helper and restricts the sink to JWT-finalized `::UploadedFile`
objects with upload-directory allowlisting. No fix commit SHA is embedded in
the images; the fix was verified by diffing the shipped Rails source of the two
immutable official image tags (exact build revisions recorded in
`source_identity.json`).

## Reproduction Steps

1. Reference: `bundle/vuln_variant/reproduction_steps.sh` (self-contained,
   idempotent; executed twice end-to-end with identical results, exit code 1 =
   "no bypass on fixed" both times).
2. What the script does:
   - Pulls immutable official images `gitlab/gitlab-ce:19.3.1-ce.0` and
     `gitlab/gitlab-ce:19.3.2-ce.0`, records digests, boots the real omnibus
     stack (nginx → gitlab-workhorse → puma/Rails → gitaly/postgresql/redis)
     with real HTTP health checks, creates a public demo project via
     `gitlab-rails runner`, and plants a canary file
     `/tmp/canary_85706v.txt` = `PRUVA85706_VCANARY_VC1_d41d8cd98f00_PCTBYTE_%zz_END`.
   - Captures target binding per version: the shipped commits.rb/files.rb
     endpoint blocks (proving 19.3.1 files endpoints lack `authenticate!` and
     19.3.2 has it), VERSION, and the workhorse route regexes.
   - Sends the unauthenticated attack matrix through the real HTTP boundary on
     **both** versions: T1 commits `.json`; T2 commits `/`; T3/T4 files
     `POST`/`PUT` trailing slash (alternate-trigger candidates); T5/T6 files
     plain / `.json` (Workhorse-intercepted controls); T7/T8
     commits/files `/authorize.json` probes.
   - Records machine-readable results to `bundle/logs/vuln_variant/matrix_results.json`;
     exit 0 only if a file read reproduces on the fixed build (true bypass).
3. Expected evidence of reproduction:
   - `vuln_t3_files_post_slash.txt` / `vuln_t4_files_put_slash.txt`: HTTP 400
     with `Invalid parameter: invalid %-encoding (PRUVA85706_VCANARY_VC1_d41d8cd98f00_PCTBYTE_%zz_END)`
     — unauthenticated arbitrary file read via the **Files API** (alternate trigger).
   - `vuln_t2_commits_slash.txt`: same echo via the trailing-slash commits
     encoding; `vuln_t1_commits_json.txt`: parent-trigger baseline echo.
   - `vuln_t5_files_post_plain.txt`: `400 branch is required` (no read) — the
     Workhorse-intercepted control.
   - All `fixed_*.txt`: HTTP 401 `{"message":"401 Unauthorized"}` — fix blocks
     every tested entry point on 19.3.2 (no bypass).

## Evidence

- Run log: `bundle/logs/vuln_variant/reproduction_steps.log` (two full runs)
- Machine-readable matrix: `bundle/logs/vuln_variant/matrix_results.json`
- Version/digest identity: `bundle/logs/vuln_variant/vulnerable_version.txt`,
  `bundle/logs/vuln_variant/fixed_version.txt`
- Workhorse regex extraction + cross-version diff:
  `bundle/logs/vuln_variant/workhorse_regexes.txt` (regexes identical between
  19.3.1 and 19.3.2 binaries — only unrelated adjacent string-fragment noise
  differs)
- HTTP captures: `bundle/vuln_variant/artifacts/http/*.txt`
- Target binding: `bundle/vuln_variant/artifacts/http/target_binding_vuln.txt`
  (19.3.1: files.rb create/update endpoints show `require_gitlab_workhorse!`
  with no `authenticate!`) and `target_binding_fixed.txt` (19.3.2:
  `authenticate!` present)
- Environment: Docker (rootless, overlay2), 8 CPUs / 31 GiB RAM, linux x86_64;
  GitLab omnibus containers with `GITLAB_ROOT_PASSWORD` env and public demo
  project `demo85706v` (id 1).

## Recommendations / Next Steps

- The shipped 19.3.2 fix **does** cover the alternate trigger found here
  (files.rb endpoints + authorize helper authenticate before the sink), so no
  emergency action is needed beyond upgrading to 19.1.8/19.2.6/19.3.2.
- Defense-in-depth for the underlying classification mismatch:
  1. Apply `::API::NO_FORMAT_SUFFIX_REQUIREMENT` to
     `post ':id/repository/commits'` (constant already exists in `lib/api.rb`
     and is used by packages/releases routes) so `/commits.json` no longer
     routes at all.
  2. Teach Workhorse's `\z`-anchored API route regexes to tolerate format
     suffixes and trailing slashes (e.g. `(?:\.[a-z]+)?/?\z`) so classification
     matches what Rails will route — currently a trailing slash defeats every
     `\z`-anchored route, including `uploads`, `artifacts`, and `metric_images`.
  3. Add a CI/rubocop invariant: any Grape endpoint under a
     Workhorse-accelerated route must call `authenticate!` /
     `authenticate_job!` (or an explicitly documented equivalent) before using
     upload params.

## Additional Notes

- Idempotency: the script tears down containers (with `-v` plus volume prune —
  the rootless-docker storage pool is a 12G tmpfs that otherwise fills with
  anonymous GitLab volumes, which was the cause of an initial boot failure) and
  re-runs cleanly; verified by two consecutive complete runs with identical
  results and exit code 1.
- The `/authorize.json` probes (T7/T8) return HTTP 500 on 19.3.1 (exception in
  the authorize handler when invoked outside the Workhorse pre-authorization
  flow) and 401 on 19.3.2 — recorded as coverage probes; they do not read files.
- Fixed-version identity is the official immutable image tag `19.3.2-ce.0`
  (the patched release named in the advisory); exact source identity in
  `bundle/vuln_variant/source_identity.json`.
