# Patch Analysis — CVE-2026-85706 (GitLab CE/EE 19.3.2 fix)

## What the fix changes (files, functions, logic)

Verified by diffing the shipped Rails source of the immutable official Docker images
`gitlab/gitlab-ce:19.3.1-ce.0` (vulnerable) and `gitlab/gitlab-ce:19.3.2-ce.0`
(fixed). No fix commit SHA is embedded in the images; the fixed release is named in
the advisory (19.1.8 / 19.2.6 / 19.3.2, released 2026-09-10).

### 1. `lib/api/commits.rb` — `post ':id/repository/commits'`

```ruby
# 19.3.1 (vulnerable)                # 19.3.2 (fixed)
post ':id/repository/commits' do     post ':id/repository/commits' do
  require_gitlab_workhorse!            require_gitlab_workhorse!
                                       authenticate!                  # <-- added
  attrs = file_params_from_body_upload ...
```

### 2. `lib/api/files.rb` — `post ':id/repository/files/:file_path'` and
`put ':id/repository/files/:file_path'`

Same change: `authenticate!` inserted between `require_gitlab_workhorse!` and
`file_params_from_body_upload`. (In 19.3.1 these two endpoints also had **no**
`authenticate!` — the only other `authenticate!` in files.rb, line 94, belongs to an
unrelated endpoint. This is the alternate-trigger surface analyzed by this stage.)

### 3. `lib/api/helpers/commits_body_uploader_helper.rb`

- `workhorse_authorize_commits_body_upload!` (used by the three hidden
  `/authorize` endpoints) now calls `authenticate!` with the comment
  *"Authenticate before Workhorse buffers the request body to disk."*
  In 19.3.1 it called `require_gitlab_workhorse!` only.
- `file_params_from_body_upload` no longer reads raw params:
  ```ruby
  # 19.3.1 (vulnerable)
  file_path = params['file.path']
  bad_request!('local file not present') unless File.exist?(file_path)
  ...
  Rack::Utils.parse_nested_query(File.read(file_path))
  rescue Rack::QueryParser::InvalidParameterError => e
    bad_request!("Invalid parameter: #{e.message}")   # e.message embeds file content

  # 19.3.2 (fixed)
  uploaded_file = params[:file]
  bad_request!('file is invalid') unless uploaded_file.is_a?(::UploadedFile)
  file_path = uploaded_file.path
  bad_request!('local file not present') unless file_path.present? && File.exist?(file_path)
  ...
  rescue Rack::QueryParser::InvalidParameterError
    bad_request!('Invalid parameter')                 # no content echo
  ```
- Path and size are now sourced exclusively from the middleware-finalized
  `::UploadedFile` object.

### 4. What was NOT changed

- **GitLab Workhorse is unchanged.** The route regexes extracted from the shipped
  `gitlab-workhorse` Go binaries of both images are identical (see
  `bundle/logs/vuln_variant/workhorse_regexes.txt`). In particular:
  - `^/api/v4/projects/[^/]+/repository/commits\z` — still defeated by any format
    suffix (`/commits.json`), so Workhorse still *fails to classify* the request;
    the defense moved entirely into Rails.
  - `^/api/v4/projects/[^/]+/repository/files/[^/]+\z` — wildcard tail, not
    suffix-defeatable (any single-segment `file_path`, with or without `.json`,
    still matches).
  - Terraform state and packages routes use prefix regexes (`.../state/.*`,
  `.../packages/generic/`) that are inherently suffix-proof.
- **No format-suffix suppression on the commits route.** `::API::
  NO_FORMAT_SUFFIX_REQUIREMENT = { format: /(?!)/ }` exists in **both** versions
  (`lib/api.rb:25`) and is applied to packages/releases routes only — not to
  `post ':id/repository/commits'`. `/repository/commits.json` still routes in
  19.3.2; only the added `authenticate!` blocks it.

## Assumptions the fix makes

1. **Authentication is enforced before any use of upload metadata** — holds for all
   three endpoints and the authorize helper.
2. **A `::UploadedFile` can only be created by trusted middleware.**
   `Gitlab::Middleware::Multipart` constructs `::UploadedFile` exclusively from the
   JWT in the Workhorse-signed `Gitlab-Workhorse-Multipart-Fields` header /
   `upload.gitlab-workhorse-upload` param (`Gitlab::Workhorse.decode_jwt_with_issuer`),
   and `UploadedFile.from_params` `File.realpath`s the path and rejects paths outside
   the allowed upload directories (`InvalidPathError`). Client-supplied multipart
   produces `Rack::Multipart::UploadedFile` / `ActionDispatch::Http::UploadedFile`,
   which fail `is_a?(::UploadedFile)` → `400 file is invalid`. Assumption verified
   sound in the shipped 19.3.2 source.
3. **Other Workhorse-accelerated endpoints authenticate independently** — verified:
   every remaining `require_gitlab_workhorse!` call site in 19.3.2 enforces its own
   auth (see matrix below).

## Coverage matrix (19.3.2, all Workhorse-accelerated body-upload surfaces)

| Route (workhorse regex) | Rails handler | Auth on 19.3.1 | Auth on 19.3.2 |
|---|---|---|---|
| `projects/[^/]+/repository/commits\z` | commits.rb POST commits | **none** (bug) | `authenticate!` |
| `projects/[^/]+/repository/commits/authorize` (sibling) | commits.rb POST .../authorize | **none** | `authenticate!` (helper) |
| `projects/[^/]+/repository/files/[^/]+\z` | files.rb POST/PUT files | **none** (same sink) | `authenticate!` |
| `projects/[^/]+/repository/files/:f/authorize` (sibling) | files.rb POST/PUT .../authorize | **none** | `authenticate!` (helper) |
| `projects/[^/]+/uploads\z` | markdown_uploads.rb POST uploads | `before { authenticate_non_get! }` | same |
| `projects|groups/[^/]+/wikis/attachments\z` | wikis.rb POST attachments | `authorize! :create_wiki` | same |
| `projects/[^/]+/alert_management_alerts/[0-9]+/metric_images\z` | alert_management_alerts.rb | `authorize!` (both handlers) | same |
| `jobs/[0-9]+/artifacts\z`, `jobs/[0-9]+/sbom_scans\z` | ci/runner.rb, ci/job_artifacts.rb | `authenticate_job!` (job token) | same |
| `projects/import` (+`/authorize`), `groups/import` | project_import.rb / group_import.rb | `before { authenticate! }` / `authorize_create_group!` | same |
| `groups/[^/]+/placeholder_reassignments/...` | group_placeholder_reassignments.rb | `authorize!` | same |
| `projects/[^/]+/terraform/state/.*` | terraform/state.rb | `authenticate!` for non-`/authorize` paths; regex is prefix-based, not suffix-defeatable | same |
| packages routes (`conan/`, `generic/`, `maven/`, `npm/`, `pypi`, `helm`, ...) | packages API | deploy token / PAT / conan JWT / maven auth; regexes are prefixes | same |
| `users\z`, `users/[0-9]+\z`, `user/avatar\z`, `groups/[^/]+\z`, `organizations...` | users.rb / group.rb / organizations.rb (multipart avatar upload) | `authenticate!` / admin | same |
| web routes (`/uploads/*`, `/import/gitlab_project`, `/import/gitlab_group`) | controllers | Warden session (`authenticate_user!`) | same |
| `geo/proxy` (EE-only) | geo.rb | `require_gitlab_workhorse!` only — returns Geo proxy config, no file access, different sink (not this CVE's root cause) | same |

## Behavior before vs. after the fix

- Before (19.3.1): `POST /api/v4/projects/<id>/repository/commits.json?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=/etc/passwd`
  (empty body, unauthenticated) → Workhorse does not classify the route (regex
  mismatch on `.json`), proxies raw request with its internal header →
  `require_gitlab_workhorse!` passes → `File.exist?`/`File.read` on the
  attacker-controlled path → 400 response whose body embeds the file content
  (via `InvalidParameterError` message) or an existence oracle
  (`local file not present` vs downstream 401/500).
- After (19.3.2): the same request returns `401 Unauthorized` before any file
  access, on all three endpoints and on the `/authorize` siblings
  (verified live on `19.3.2-ce.0`, see `bundle/vuln_variant/artifacts/http/fixed_*.txt`).

## Gaps / conclusions

- **No bypass found on 19.3.2** by source audit or runtime testing of the
  alternate-trigger matrix: the files.rb endpoints (the only other callers of the
  vulnerable sink, unauthenticated in 19.3.1) are now authenticated, and their
  Workhorse regex tail (`[^/]+`) cannot be defeated by a format suffix — only a
  non-single-segment clean path (e.g. trailing slash) defeats it, and such paths do
  not route to the vulnerable Grape endpoints (verified live, see
  `vuln_t3/t4` results in `bundle/logs/vuln_variant/matrix_results.json`).
- Residual (defense-in-depth) gaps, not exploitable today:
  1. Workhorse's `\z`-anchored classification regexes still disagree with Grape's
     implicit `(.:format)` routing; the fix defends the known handlers but the
     classification mismatch itself persists.
  2. New endpoints added under `\z`-anchored Workhorse routes with only
     `require_gitlab_workhorse!` would reintroduce the bug class — recommend a CI
     invariant requiring `authenticate!`/`authenticate_job!` on all such endpoints.
  3. Applying `::API::NO_FORMAT_SUFFIX_REQUIREMENT` to the commits route would stop
     `/repository/commits.json` from routing at all.

## Target threat model / security policy scope

GitLab's security policy (https://about.gitlab.com/security/ and the project
SECURITY.md disclosure process) treats unauthenticated arbitrary file read via the
REST API as an in-scope, critical vulnerability — this CVE itself is a
GitLab-published advisory (CVSS 10.0, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) with
official patched releases. The variant candidates analyzed here are therefore within
the target's own declared threat model; no candidate relies on behavior GitLab
documents as accepted (e.g., admin-authenticated import/export features were not
recounted as variants).
