# RCA Report — CVE-2026-85706

## Summary

CVE-2026-85706 is an unauthenticated arbitrary local file read in GitLab CE/EE,
reachable through the Repository Commits REST API. GitLab Workhorse classifies
the commits body-upload accelerator route with an **anchored regex on the clean
(escaped) URI path**; appending a `.json` format suffix to
`POST /api/v4/projects/:id/repository/commits` defeats that classification, so
Workhorse proxies the raw request to Rails with its signed
`Gitlab-Workhorse` header. The Grape endpoint `post ':id/repository/commits'`
(lib/api/commits.rb) calls `require_gitlab_workhorse!` but, in vulnerable
versions, **never calls `authenticate!`**, and
`API::Helpers::CommitsBodyUploaderHelper#file_params_from_body_upload`
(lib/api/helpers/commits_body_uploader_helper.rb) takes the attacker-supplied
flat request parameter `file.path` and uses it directly as a filesystem path:
`File.exist?(params['file.path'])` followed by `File.read(file_path)`. When the
request also carries a parameter literally named `Content-Type` with value
`application/x-www-form-urlencoded`, the file content is fed to
`Rack::Utils.parse_nested_query`, and any invalid percent-escape in the file
(e.g. `%zz`) makes `Rack::QueryParser::InvalidParameterError` embed **the file
content** in its message, which the vulnerable rescue clause echoes verbatim in
the HTTP 400 response body. Files without a parse-triggering byte are still
confirmed readable through an existence oracle (`local file not present` vs a
downstream 401/500). This was reproduced end-to-end on the real omnibus product
(nginx → gitlab-workhorse → puma/Rails) with no sanitizers and no
authentication.

## Impact

- **Package/component affected**: GitLab CE/EE omnibus — `lib/api/commits.rb`
  (Repository Commits API), `lib/api/helpers/commits_body_uploader_helper.rb`,
  and the GitLab Workhorse body-upload route classification for
  `/api/v4/projects/[^/]+/repository/commits`.
- **Affected versions**: 18.7 before 19.1.8, 19.2 before 19.2.6, 19.3 before
  19.3.2. Tested vulnerable: `gitlab/gitlab-ce:19.3.1-ce.0`
  (image digest `sha256:f63df4c43029fe91db370609c0b40a1e3585cebd06e3e9637d93a9a3030eb86e`).
  Tested fixed: `gitlab/gitlab-ce:19.3.2-ce.0`
  (image digest `sha256:05453dd1d9aba27c2c487613141596868409b4d03247647f7d66cb0b36f321b8`).
- **Risk level**: Critical (CVSS 3.1 10.0, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N).
  Any unauthenticated network attacker who can reach the web endpoint and can
  name an existing, anonymously routable project can read arbitrary files
  readable by the `git` service account — including `/etc/gitlab/gitlab-secrets.json`
  (demonstrated readable via the existence oracle: HTTP 500 downstream
  processing vs `local file not present` for a missing file), database
  credentials, Gitaly/Praefect tokens, and private repository content. With the
  secrets file disclosed, an attacker can forge signed cookies/tokens
  (`I:H` integrity impact in the CVSS vector).

## Impact Parity

- **Disclosed/claimed maximum impact**: unauthenticated arbitrary file read
  (info leak of any file readable by the GitLab service account), critical
  severity.
- **Reproduced impact from this run**: full parity for the claimed read
  primitive —
  - **Full content echo, attacker-chosen path**: a canary file planted at
    `/tmp/canary_85706_a1.txt` containing
    `PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END` was read
    unauthenticated and its complete content was reflected in the response:
    `{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END)"}`.
    Repeated with a distinct token in a second fresh-process attempt.
  - **Existence oracle for arbitrary paths**: `/etc/passwd` (existing) proceeds
    to downstream authorization (401), while a non-existent path returns
    `400 ... local file not present` — confirming `File.exist?` is consulted on
    the attacker-controlled path.
  - **Sensitive-file readability**: `/etc/gitlab/gitlab-secrets.json` produced
    a downstream HTTP 500 (content read and processed), not
    `local file not present`, proving the secrets file is opened by the
    vulnerable code path.
  - **Operative bypass control**: the identical request **without** the
    `.json` suffix returns `401 Unauthorized` on the vulnerable build —
    Workhorse classifies that route and authentication is enforced — proving
    the `.json` suffix is the operative route-matching bypass.
  - **Fixed negative control**: the identical `.json`-suffixed request against
    `19.3.2-ce.0` returns `401 Unauthorized` in both attempts (no echo, no
    oracle) because `authenticate!` now runs before the upload handling.
- **Parity**: `full` for the claimed info-leak impact.
- **Not demonstrated**: post-read weaponization (e.g., forging signed requests
  from `gitlab-secrets.json`) — out of scope for the filed claim, which is the
  file-read primitive itself.

## Root Cause

1. **Missing authentication on a Workhorse-only endpoint** —
   `lib/api/commits.rb` (v19.3.1), endpoint
   `post ':id/repository/commits'`:
   ```ruby
   post ':id/repository/commits' do
     require_gitlab_workhorse!
     attrs = file_params_from_body_upload   # <-- reads a file BEFORE any authz
     ...
   ```
   The endpoint trusts that the Workhorse body-upload middleware already
   finalized the upload (the design assumption is that `file.path`/`file.size`
   only ever come from Workhorse's signed multipart finalization), so it never
   calls `authenticate!` and reads the raw Grape params.
2. **Raw request parameter used as a filesystem path** —
   `lib/api/helpers/commits_body_uploader_helper.rb` (v19.3.1):
   ```ruby
   def file_params_from_body_upload
     file_path = params['file.path']
     bad_request!('local file not present') unless File.exist?(file_path)
     ...
     elsif media_type == 'application/x-www-form-urlencoded'
       Rack::Utils.parse_nested_query(File.read(file_path)).deep_symbolize_keys!
     rescue Rack::QueryParser::InvalidParameterError => e
       bad_request!("Invalid parameter: #{e.message}")   # e.message embeds file content
   ```
   `params['file.path']`, `params['file.size']` and `params['Content-Type']`
   are ordinary flat request parameters (Rack keeps `file.path` flat because
   its nested-query syntax uses `file[path]`, not `file.path`), so a plain
   URL query string controls the path that `File.exist?`/`File.read` open. The
   `requires :file, type: WorkhorseFile` declaration is satisfied by a blank
   `file=` parameter because `WorkhorseFile.parse` returns `nil` for blank
   values.
3. **Workhorse route-matching bypass** — Workhorse decides whether a request is
   a commits body-upload (which it would intercept and finalize) by matching an
   anchored regex against the *clean* (escaped) request path. The `.json`
   format suffix makes the path not match the accelerator route, so Workhorse
   simply proxies the raw request to Rails — while Rails/Grape *strips* the
   `.json` suffix and routes it to the commits endpoint. Result: the endpoint
   is reachable with Workhorse's signed header but without Workhorse's
   upload finalization and without authentication.
4. **Fix (v19.3.2)** — public tag diff `v19.3.1 → v19.3.2`:
   - `authenticate!` added to `post ':id/repository/commits'` and to
     `workhorse_authorize_commits_body_upload!` ("Authenticate before
     Workhorse buffers the request body to disk").
   - `file_params_from_body_upload` now trusts **only
     middleware-finalized upload metadata**:
     `uploaded_file = params[:file]; bad_request!('file is invalid') unless
     uploaded_file.is_a?(::UploadedFile)`; path and size come from the
     `UploadedFile` object, never raw params.
   - Rescue clauses no longer echo `e.message`.

## Reproduction Steps

1. Reference: `bundle/repro/reproduction_steps.sh` (self-contained; executed
   twice consecutively, both runs passing).
2. What the script does:
   - Pulls the immutable official images `gitlab/gitlab-ce:19.3.1-ce.0`
     (vulnerable) and `gitlab/gitlab-ce:19.3.2-ce.0` (fixed) and records their
     digests.
   - Boots the real omnibus product (nginx → gitlab-workhorse → puma/Rails →
     gitaly/postgresql/redis) in Docker, waits for a real HTTP health check
     (`/users/sign_in` = 200, `/api/v4/version` responding).
   - Captures in-container target binding: the shipped
     `commits_body_uploader_helper.rb` (5 raw `file.path` references, 0
     `authenticate` references on 19.3.1; `authenticate!` present on 19.3.2).
   - Creates a publicly routable demo project through the real Rails service
     (`gitlab-rails runner`), needed only so the URL routes.
   - Sends the unauthenticated attacker request through the real HTTP
     boundary:
     `POST /api/v4/projects/1/repository/commits.json?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=<target>`
     with header `Content-Type: application/x-www-form-urlencoded` and empty
     body.
   - Vulnerable build, attempt 1 (fresh boot): canary-with-`%zz` read (content
     echo), `/etc/passwd` (existence oracle),
     `/etc/gitlab/gitlab-secrets.json` (sensitive-file oracle), missing-file
     control, and the no-`.json`-suffix control (401).
   - Vulnerable build, attempt 2: `docker restart` for fresh processes, a
     second distinct canary, plus controls.
   - Fixed build, attempts 1 and 2 (fresh boot + restart): identical
     `.json`-suffixed attack.
3. Expected evidence of reproduction (all observed in this run):
   - `repro/artifacts/http/vuln_attempt1_canary_response.txt` /
     `vuln_attempt2_canary_response.txt`: HTTP 400 with
     `Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_<per-attempt-token>_PCTBYTE_%zz_END)`
     — arbitrary file content disclosure.
   - `vuln_attempt1_missingfile_response.txt`: HTTP 400
     `local file not present` (vs `/etc/passwd` 401, `gitlab-secrets.json`
     500) — existence oracle.
   - `vuln_attempt1_nosuffix_response.txt` / `vuln_attempt2_nosuffix_response.txt`:
     HTTP 401 — the `.json` suffix is the operative bypass.
   - `fixed_attempt1_canary_response.txt` / `fixed_attempt2_canary_response.txt`:
     HTTP 401 `Unauthorized` — fixed version fails closed.

## Evidence

- Script + diagnostics: `bundle/repro/reproduction_steps.sh`,
  `bundle/logs/reproduction_steps.log` (per-run diagnostic transcript).
- Finalized per-request evidence (request URL, headers, status, body):
  `bundle/repro/artifacts/http/*.txt`, SHA-256-bound in
  `bundle/repro/runtime_manifest.json`.
- Key excerpts (vulnerable 19.3.1, unauthenticated):
  - Canary attempt 1 →
    `{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END)"}`
  - Canary attempt 2 (fresh processes) →
    `{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA2_5eed2badcafe_PCTBYTE_%zz_END)"}`
  - Missing file → `{"message":"400 Bad request - local file not present"}`
  - `/etc/passwd` → `{"message":"401 Unauthorized"}` (read succeeded, parse
    clean, downstream auth failure — existence oracle)
  - `/etc/gitlab/gitlab-secrets.json` → `{"message":"500 Internal Server Error"}`
    (secrets content read and processed downstream)
  - Same request without `.json` → `{"message":"401 Unauthorized"}` (Workhorse
    classifies the route)
- Fixed 19.3.2, same attack → `{"message":"401 Unauthorized"}` (both attempts).
- Environment: Docker (rootless) on Linux x86-64, 4 vCPU, 31 GB RAM; official
  images as above; no sanitizers; product-mode proof through the real
  nginx/workhorse/puma HTTP boundary.

## Recommendations / Next Steps

- **Upgrade** to GitLab 19.1.8 / 19.2.6 / 19.3.2 or later immediately; the
  endpoint now authenticates before Workhorse buffers the body and only trusts
  middleware-finalized `UploadedFile` metadata.
- **Defense-in-depth**:
  - Route/classification logic in proxies (Workhorse) and application routing
    (Rails/Grape format-suffix stripping) must agree on path canonicalization;
    anchored regexes on escaped paths should account for format suffixes.
  - Never echo raw exception messages (`e.message`) to API clients.
  - Parameters sourced from signed middleware handoff (e.g. `file.path`,
    `file.size`) should be carried in a tamper-proof envelope, not re-accepted
    from the raw request.
- **Testing**: regression test that an unauthenticated
  `POST /api/v4/projects/:id/repository/commits.json` with flat
  `file.path`/`file.size`/`Content-Type` query parameters returns 401 before
  any filesystem access, on both the accelerator-classified and non-classified
  (suffixed) path shapes.

## Additional Notes

- **Idempotency**: the script removes prior containers at start, reuses pulled
  images, truncates its diagnostic log per run, and re-creates the demo project
  (idempotent `find_by(name:)`). It was executed twice consecutively with
  identical CONFIRMED results.
- **Limitations / edge cases**:
  - Full content *echo* requires a parse-triggering byte in the target file
    (invalid `%`-escape such as `%zz`, or invalid UTF-8). Benign-charset files
    (e.g. stock `/etc/passwd`) still yield a reliable existence/readability
    oracle (`local file not present` vs downstream 401/500); an attacker can
    force echo for any file by causing downstream processing of parsed params,
    or simply exploit the oracle.
  - The exploit needs an existing, anonymously routable (public or internally
    exposed) project id in the URL; no credentials of any kind are required.
  - Raw `../` traversal inside the URL *path* is blocked by GitLab's generic
    path-traversal middleware on both builds; the operative bypass is the
    `.json` suffix on the route plus the `file.path` query parameter, exactly
    as claimed.
