DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-85706 Deep Dive — GitLab Repository Commits API Auth Bypass Leads to Unauthenticated Arbitrary File Read (CVSS 10.0)

Overview

Item Detail
CVE CVE-2026-85706
Target GitLab CE/EE (self-managed)
CVSS 3.1 10.0 (Critical)AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N
CWE CWE-22 (Path Traversal), CWE-35
Affected versions 18.7–19.1.7, 19.2.0–19.2.5, 19.3.0–19.3.1
Fixed in 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10)
Reporter s3ntago (HackerOne #3909881)
Notes Added to CISA KEV; exploited in the wild within 24 hours of disclosure

In one sentence: a single unauthenticated HTTP request can read any file the GitLab server process has permission to read. The file-upload handling behind the Repository Commits API and Repository Files API skips authentication entirely and passes a client-controlled absolute path straight into File.read.

Background — GitLab's request pipeline

To understand this bug, you first need to see how GitLab handles a single request:

Client → Nginx → GitLab Workhorse → Puma → Grape (Rails API)
Enter fullscreen mode Exit fullscreen mode
  • Workhorse is a Go-based reverse proxy sitting in front of Rails. It intercepts heavy I/O work — large Git transfers, file uploads — before it ever reaches Rails. For file uploads, Workhorse writes the file to a temp location itself and hands the result (path, size) to Rails via a signed header (Gitlab-Workhorse-Api-Request JWT).
  • Any request Workhorse doesn't intercept is passed through to Puma, which routes it to Grape (the framework GitLab uses for API routing).

The key detail is that these two components handle URLs differently.

  • Workhorse's route-matching regex operates on EscapedPath() — the raw, still-percent-encoded path — anchored with \z.
  • Puma, on the other hand, decodes %XX percent-encoding before handing the request to Grape.

Root cause — a percent-decoding differential

A normal commit-lookup request looks like this:

POST /api/v4/projects/1/repository/commits
Enter fullscreen mode Exit fullscreen mode

Workhorse classifies any request whose path ends exactly in commits as its own upload-handling route. But watch what happens when an attacker percent-encodes just one character:

POST /api/v4/projects/1/repository/%63ommits
Enter fullscreen mode Exit fullscreen mode
  • To Workhorse, this path does not end in commits (%63ommitscommits), because its regex never decodes percent sequences. It fails to classify the request as an upload route and simply lets it fall through as ordinary proxied traffic.
  • Puma, however, decodes %63c before handing off to Grape, so Grape sees a perfectly normal route match: POST :id/repository/commits.

In other words, you end up with a request that Workhorse never treated as an upload, but Rails processes as if it were a legitimate upload endpoint. The same trick works by percent-encoding any single character in commits, repository, or files. Further analysis also showed that appending a .json format suffix or an extra trailing slash produces the same effect — defeating Workhorse's \z-anchored regex.

Source-level analysis — how this reaches unauthenticated file reads

1) require_gitlab_workhorse! is not authentication

The commit-creation endpoint behind the Repository Commits API was structured roughly like this (pseudocode reconstructed from public exploit writeups):

# Grape endpoint (vulnerable version)
post ':id/repository/commits' do
  require_gitlab_workhorse!          # ← only this
  # authenticate!                    # ← this line was missing

  file_params = file_params_from_body_upload(params)
  # ... commit action processing ...
end
Enter fullscreen mode Exit fullscreen mode

require_gitlab_workhorse! only checks whether the request passed through Workhorse — specifically, whether the Gitlab-Workhorse-Api-Request JWT header is present. The problem: Workhorse attaches that signed header to every request it proxies, regardless of whether it classified the request as an upload. So even after the percent-encoding trick bypasses Workhorse's upload interception, the request is still physically proxied through Workhorse, and the signature is still attached.

As a result, require_gitlab_workhorse! doesn't actually verify a user's identity — it just confirms the request came through Workhorse. This endpoint was originally supposed to follow that check with authenticate! (real session/personal-access-token verification). With that line missing, the handler is reachable regardless of login state.

2) file_params_from_body_upload trusts client input directly

In a legitimate flow, file, file.path, and file.size should only ever come from Workhorse having actually written a file to a temp location and reporting the result. But since this request bypassed Workhorse's upload logic entirely, those values are just attacker-forged query parameters.

# CommitsBodyUploaderHelper (vulnerable version, pseudocode)
def file_params_from_body_upload(params)
  path = params['file.path']   # attacker-controlled absolute path, used as-is
  size = params['file.size']

  return nil unless File.exist?(path)   # also usable as a file-existence oracle
  File.read(path)                       # arbitrary file read happens here
end
Enter fullscreen mode Exit fullscreen mode

Parameter validation such as requires :file, WorkhorseFile was found to pass even with a blank file= value, since it coerces to nil. That means a single request like this reaches the vulnerable sink:

POST /api/v4/projects/1/repository/%63ommits
    ?file=
    &file.path=/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml
    &file.size=1
Content-Type: application/x-www-form-urlencoded
Enter fullscreen mode Exit fullscreen mode

At this point, an attacker can already probe file existence without authentication (an existence oracle). Getting the actual file contents back requires one more step.

3) Leaking content through an error message

When Content-Type: application/x-www-form-urlencoded is used, the file content that was just read gets handed to Rack::Utils.parse_nested_query, which tries to parse it as a query string.

# pseudocode — urlencoded branch
file_content = File.read(path)
Rack::Utils.parse_nested_query(file_content)   # tries to parse file bytes as a querystring
Enter fullscreen mode Exit fullscreen mode

Ordinary configuration files (YAML, JSON, etc.) very often contain a % character not followed by two hex digits. When that happens, Rack::QueryParser::InvalidParameterError is raised — and its message embeds the raw bytes it was trying to parse.

Rack::QueryParser::InvalidParameterError:
  invalid %-encoding (<raw file content appears here>)
Enter fullscreen mode Exit fullscreen mode

Because this exception message is returned verbatim in the HTTP 400 response body, an unauthenticated attacker can retrieve the full file content. Interestingly, the JSON content-type branch (which uses the Oj parser) doesn't trigger this error, so the request must specifically use urlencoded content to get content echoed back.

4) An alternate trigger — the Repository Files API

The same file_params_from_body_upload sink is also reused by the POST/PUT :id/repository/files/:file_path endpoints. Analysis found that this route can be bypassed not with percent-encoding but simply by adding a trailing slash, which also defeats Workhorse's \z-anchored regex. Unlike the Commits API, this variant was reported to work without needing any accessible project ID at all.

PoC

Patch analysis — what changed in 19.3.2

The fix commit (0d9ce3e7 on master, backported as 1fe30154 / b43c8b26 / 0ff7b6b2) makes three changes.

  1. Added authenticate! — Real user authentication is now enforced on all three commits/files-related endpoints, plus the preceding /authorize step. The check is no longer "did this pass through Workhorse" but "is this an authenticated user."
  2. Restricted where file metadata can come fromfile.path and file.size are no longer read directly from request parameters. They're only taken from the UploadedFile object that the Workhorse middleware actually created, closing off the path a client could use to inject an arbitrary location.
  3. Stopped exposing parser exception messages — Exceptions raised by Rack::Utils.parse_nested_query are no longer surfaced verbatim in the response body, so even if a similar parsing path were hit again, it wouldn't lead to content echo.

Top comments (0)