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)
-
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-RequestJWT). - 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
%XXpercent-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
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
- To Workhorse, this path does not end in
commits(%63ommits≠commits), 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
%63→cbefore 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
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
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
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
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>)
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.
-
Added
authenticate!— Real user authentication is now enforced on all three commits/files-related endpoints, plus the preceding/authorizestep. The check is no longer "did this pass through Workhorse" but "is this an authenticated user." -
Restricted where file metadata can come from —
file.pathandfile.sizeare no longer read directly from request parameters. They're only taken from theUploadedFileobject that the Workhorse middleware actually created, closing off the path a client could use to inject an arbitrary location. -
Stopped exposing parser exception messages — Exceptions raised by
Rack::Utils.parse_nested_queryare 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)