Vulnerability Research
GitLab CVE-2026-85706: how files reached error responses
GitLab's commits API could read a request-supplied local file before authenticating the user and return a fragment through a parsing error; this actively exploited vulnerability calls for prompt patching and request-level investigation of what actually reached the client.

In this article
A request to create a commit can be rejected after content from the server has already entered its error response. That ordering is central to CVE-2026-85706. The API first treated a supplied path as an uploaded file, read it and parsed its contents. User authentication came later. If parsing failed with a message that included the offending content, the request could return that content without ever reaching the operation that creates a commit.
GitLab issued an emergency patch on September 10, 2026. CISA added the vulnerability to its Known Exploited Vulnerabilities catalog the next day, when watchTowr also reported probes against its honeypots. GitLab assigned a CVSS 3.1 score of 10.0. Those facts establish the urgency of patching. They leave an important incident-response question: which files did the server read, and which contents actually left it? The answers determine different investigative and credential-remediation work.
Our recommendation is to patch affected self-managed installations on their supported branch immediately, preserve existing logs, and investigate in parallel. At publication, the maintained 19.3 and 19.2 branches require at least 19.3.2 and 19.2.6 respectively. The current stable minor release is 19.4; 19.4.0 includes the fix. Older branches need a supported upgrade path. Understanding the failure first makes both the patch and the disclosure assessment easier to verify.
1 Why creating a commit involves reading a file
A GitLab request normally passes through Workhorse before reaching the Rails application. Workhorse is written in Go and handles uploads, downloads and other work that would otherwise occupy Rails workers for too long. The commits API can accept several file operations in one request, with a potentially large body. Buffering those bytes to disk and having Rails read the parameters back from that buffer is a reasonable engineering arrangement.
Two different paths matter here. The commit action's actions[][file_path] names a file to create or change inside the repository. The vulnerable file.path describes a temporary file on the server's local filesystem. The first belongs to the user's proposed change. The second should be internal upload metadata. They derive their authority from different places.
The normal flow is visible in processRequestBody(). Workhorse saves the original body, obtains fields generated from the upload result, replaces the request body with those fields, tracks the saved upload and forwards the request. Rails receives a description of the buffer. The original commit content is still in the file, waiting to be parsed.
GitLab already had a mechanism for validating such descriptions. Its Multipart middleware extracts values from signed upload metadata and passes them to UploadedFile.from_params(). The resulting object represents a file processed through the upload machinery. A request string that happens to be named file.path does not carry the same guarantee.
The application went back to that raw string. In GitLab 19.3.1, file_params_from_body_upload() reads params['file.path'], checks whether the path exists and selects a parser from the supplied content type. For form-encoded content, it calls File.read(file_path) directly. The provenance established by the upload object is absent from this read.
# GitLab 19.3.1: selected lines
file_path = params['file.path']
bad_request!('local file not present') unless File.exist?(file_path)
# application/x-www-form-urlencoded branch
Rack::Utils.parse_nested_query(File.read(file_path)).deep_symbolize_keys!
File.exist? answers whether a file exists. It cannot establish that this request uploaded that file. Once a caller-supplied string is mistaken for internal upload metadata, the read uses the GitLab process's filesystem privileges, even though the intended business operation is merely to create a repository file. A nonexistent path, an unreadable file or a deployment without that file changes the outcome. Arbitrary file reading describes control over the selected path; it does not grant the process new operating-system permissions.
2 Authentication arrives after the read
An untrusted path alone does not explain unauthenticated access. The order in the 19.3.1 commits endpoint does: it calls require_gitlab_workhorse!, then the file-parsing helper, and only afterwards authorize_push_to_branch!. The authenticate! call that requires a signed-in user is inside that final helper.
The name require_gitlab_workhorse! deserves care. It verifies transit through Workhorse. An anonymous external visitor also reaches Rails through that proxy. Trusting the forwarding component does not establish that its upload handler validated every forwarded field, or that the visitor may read a server-side file.
The endpoint has earlier checks for an enabled repository and permission to read its code. This anonymous path therefore needs a project whose repository can be read without signing in; other visibility settings on a public project may still restrict access. The new upstream regression uses a public project. Making projects private changes that entry condition, but it leaves the underlying path-trust defect in place and is not evidence that the patch has been installed.
Why does normal upload processing not overwrite the hostile metadata? Workhorse and Rails interpret the request path at different stages. Workhorse chooses its body uploader through a specific route pattern, matching a cleaned, escaped path. Its routing logic also has a default forwarding route. When a request misses the intended upload processing but still reaches the backend commits endpoint, the application's assumption about its internal fields fails. GitLab's investigation guidance explicitly includes encoded routes and parameter names. A log search restricted to one literal URL can miss these requests.
The dangerous combination is a reachable endpoint, a retained caller-supplied local path and code that consumes that path before authenticating the user. A later commit-permission check can correctly reject the operation after the file has already been read. A security check must precede the side effects it is intended to govern.

Scroll sideways to read the diagram.
3 How the file becomes an error message
Reading a file into the process does not automatically give its contents to the client. Here, the bytes are interpreted as a URL-encoded form even though the file may contain configuration, ordinary text or other data. The next step depends on those bytes.
The 19.3.1 dependency lockfile pins Rack 2.2.23 and URI 1.1.1. Rack's parse_nested_query() checks size and parameter-count limits, then splits the form. This version's default separators include both & and ;. Each component is split at its first =, and its name and value are decoded separately. A percent escape requires two hexadecimal characters after %: %25 is a valid representation of a percent sign; %oo is invalid.
URI's decoder includes the string currently being decoded in the exception it raises for an invalid escape. Rack preserves the message when wrapping the exception as InvalidParameterError. The vulnerable GitLab handler then interpolates e.message into its public error. A fragment of a server-side file has now passed through three functions and into the HTTP response.
# URI 1.1.1: decoder error
raise ArgumentError, "invalid %-encoding (#{str})" if /%(?!\h\h)/.match?(str)
# GitLab 19.3.1: selected error-handling lines
rescue Rack::QueryParser::InvalidParameterError => e
bad_request!("Invalid parameter: #{e.message}")
A file containing only synthetic values makes the sequence concrete:
note=calm&token=DEMO%oops&tail=ok
note and calm decode successfully. So does the next name, token. Its value, DEMO%oops, fails. That value appears in the error; the preceding note=calm does not, and execution has not reached tail=ok. Replacing %oops with %25oops makes decoding succeed. The resulting value still contains a percent sign, but it does not trigger this exception. Merely finding a percent sign in a file is therefore too imprecise a test.

Scroll sideways to read the diagram.
We executed the pinned Rack parser locally in Ruby WASM, loading both original URI 1.1.1 decoder methods. Four controls used an invalid escape, a valid escape, text without delimiters and semicolon-delimited text. The first produced invalid %-encoding (DEMO%oops). The valid escape produced ordinary parameters. When the entire input was just DEMO%oops, the exception contained the entire input. The semicolon case again included only the failing component.
The 33-byte example puts 9 bytes into the decoding error. Constructing a JSON response using the old helper's message format produces an 81-byte response example. Its additional bytes come from the error prefix and JSON wrapper. The delimiter-free, 9-byte input produces an example of the same 81-byte length. Treating response size as disclosure size misreads both cases.
Local experiment scope and results
The experiment runs the Rack 2.2.23 QueryParser source file in a Ruby 3.3.3 WASM runtime. Rack::Utils.unescape delegates to URI exactly as in the inspected version; URI 1.1.1's decode_www_form_component and _decode_uri_component methods are loaded verbatim. This tests parsing and error strings. Rails, Workhorse and a complete GitLab installation were not started. JSON response examples are constructed from the inspected error format, not captured server responses. Download all four inputs, execution results and source references.
That explanation has a specific scope: the form-decoding exception path. Oversized files, excessive parameters, incompatible parameter structures and JSON parsing failures can take different branches with different messages. The old helper also catches two other Rack exception classes. An absence of this particular error cannot establish that an entire installation has never disclosed data. The file's contents at the time, deployed dependencies and complete response matter when classifying an individual request.
4 The patch repairs the trust relationship
GitLab's fix changes the commits endpoint and both repository-file creation and update endpoints. Its most direct change is to call authenticate! before parsing the file. The upload preauthorization endpoint also requires an authenticated user earlier. Anonymous requests now end before this application-level file read.
The next change addresses provenance. The new helper takes params[:file], explicitly requires an UploadedFile, then uses that object's path and actual size. A request without such an object is rejected. The read now depends on upload-provenance validation and no longer accepts an arbitrary request string as its file source.
The endpoint already declared a WorkhorseFile type, so why was another check needed? Its parse() returns nil for a blank value. That declaration did not establish provenance for the separate file.path and file.size parameters the helper subsequently consumed. The patch closes that gap by explicitly requiring the trusted upload object at the point of use.
# Patched helper: selected lines
uploaded_file = params[:file]
bad_request!('file is invalid') unless uploaded_file.is_a?(::UploadedFile)
file_path = uploaded_file.path
check_large_request_rate_limit!(uploaded_file.size)
# Error handling in the same patch
rescue Rack::QueryParser::InvalidParameterError
bad_request!('Invalid parameter')
Actual size is important too. The old file.size parameter governed a large-request rate-limit check. It was not a length argument to File.read. Declaring a tiny size did not limit reading to a few bytes. The patched rate-limit check and multipart content length use the upload object's size. A new test explicitly covers a declaration of 1 when the actual body exceeds the threshold.
The third change removes reflected exception content. Each of the three rescued Rack errors now produces a fixed explanation. An authenticated user sending malformed form data no longer receives the parser's original diagnostic string through those handlers. Authentication, object provenance and output handling govern different stages. Fixing one while retaining the other assumptions would leave an incomplete repair.
The history runs through two adjacent changes. The December 8, 2025 upload change moved these endpoints to buffered request bodies. The following day's form-support change added the parser and exception interpolation examined here. The inspected 18.6.0 commits path had not adopted this helper; 18.7.0 contains it. That release-snapshot comparison agrees with the vendor's affected-range start at 18.7. Reachability still depends on the installation's configuration.
The public regressions explain the intended repair well. A shared test places a harmless marker in a temporary file, submits existing and nonexistent paths, and requires both anonymous requests to be rejected without revealing either the marker or a file-existence distinction. Another commits test submits a malformed form as an authenticated user and requires the error to omit the marker. We inspected those upstream tests; they are not presented here as a complete GitLab test run performed by SOSEC.
5 Patch exposure and investigate returned content
The affected products are self-managed GitLab CE and EE. The vendor's ranges are 18.7 up to, but excluding, 19.1.8; 19.2 before 19.2.6; and 19.3 before 19.3.2. GitLab.com and GitLab Dedicated were patched by GitLab. Read the first-fixed releases alongside the current maintenance policy: 19.1.8 was a historical fix, while 19.1 is no longer one of the three maintained branches at our publication check.
| Current branch | Action | Important condition |
|---|---|---|
| 19.3 | Upgrade to 19.3.2 | Published fix on the same branch. Do not restore public exposure on 19.3.1. |
| 19.2 | Upgrade to 19.2.6 | Published fix on the same branch. 19.2.5 is not a safe connected fallback. |
| 18.7–19.1 | Restrict exposure and follow an upgrade path to a maintained branch | 19.1.8 is only a historical repair floor. Observe required upgrade stops and background migrations. |
| Moving to the current stable minor | Deploy 19.4.0 after compatibility and migration checks | Released September 17 and contains the fix. Do not defer a same-branch patch while planning the minor upgrade. |
Even a same-branch update includes database migrations in this release. The official upgrade notes specify downtime for single-node installations: migrations must finish before GitLab starts. Multi-node deployments need the zero-downtime upgrade procedure to maintain availability. Version 19.3.2 also includes post-deploy migrations; verify their completion instead of checking only that processes have restarted.
These versions were checked on September 20, 2026 UTC. Recheck the current patch and installation-specific procedure through the official upgrade entry point when executing the change. If immediate patching is impossible, restrict the instance's network entry points to trusted access, accounting for integrations, Runners and other paths that remain reachable. External collaboration, Git-over-HTTPS operations and automation may be disrupted; make that cost explicit. A WAF rule covering only one literal URL cannot cover the path differences described above.
Preserve Rails api_json.log, Workhorse access logs and relevant proxy logs, including rotations, before upgrading. GitLab's investigation guidance uses correlation_id to connect request parameters, Rails api_error and Workhorse written_bytes for the same request. Error fields may contain sensitive fragments, so protect the evidence copies accordingly.
Once those records are joined, inspect the error body and use response size as corroboration. A 400 can represent a missing file, a parsing failure or another request error. A final 401 does not describe every action that preceded it. A complete error body containing a file fragment is more informative about returned content than a status code alone. Missing or truncated logs and proxy-transformed responses leave uncertainty that a fixed byte threshold cannot resolve.
Filter individual parameter values rather than discarding an entire log line because it contains a normal upload directory. One request can contain both ordinary and abnormal fields. Keep raw encodings and a separate normalized view for searching; retaining only a decoded value discards evidence needed to understand how the two layers interpreted the request. The investigation window begins when the instance ran an affected version with the relevant entry point exposed, not automatically on the advisory date.
For a sensitive value that was actually returned, identify its service, validity and privileges before revoking or replacing it, and inspect subsequent account activity. Incomplete logs may justify more conservative rotation based on the exposure window and credential authority. GitLab's own encryption master keys have an additional restriction. At our retrieval date, GitLab explicitly states that db_key_base has no supported rotation path and directs customers to GitLab Support before operations that could cause data loss. The restore documentation explains the dependency between keys and encrypted data; arbitrary deletion or replacement can make existing data unreadable.
Rollback includes data compatibility. A GitLab backup must be restored to exactly the matching version and CE/EE edition; a database migrated by a newer release cannot simply be handed to an arbitrary old binary. If restoring an older backup returns the service to a vulnerable version, keep it isolated until it has been upgraded again. For a repaired 19.2 or 19.3 branch, this vulnerability's floors are 19.2.6 and 19.3.2 respectively. Those floors do not authorize arbitrary cross-branch downgrades.
Close the change with checks that exercise different properties: every Rails and Workhorse node or container actually runs the repaired release; legitimate JSON, form and multipart commits still work; anonymous requests are rejected before application-level file processing; unverified upload descriptions are rejected; an authenticated malformed form cannot reflect a harmless marker; and large legitimate requests remain governed by their actual size. Run malformed-input regressions only in an isolated environment you own, with marker files containing no secrets. Keep the credential investigation separate from version and service recovery, recording both completed remediation and time periods that remain uncertain.
6 Error handling also moves data
This vulnerability connects components that are often reviewed separately: proxy forwarding, upload metadata, filesystem reads, form parsing and error output. Each is familiar in isolation. The failure comes from what successive components believe the same value means. A request string becomes an internal path, a file becomes a form, and a diagnostic exception becomes a message suitable for an outside visitor.
For similar endpoints, follow the request's actual execution order and ask three concrete questions. Are permissions checked before the first read or state change? Does the object consumed by application code retain its verified origin? Can an exception send server-side content back out? The CVE-2026-85706 patch addresses all three. For responders, completion is equally concrete: the entry point is repaired, normal commits work, returned sensitive values have been investigated and handled, and unresolved parts of the exposure remain explicitly recorded.
Research basis
Research basisReviewed relevant GitLab 18.6, 18.7, 19.3.1, first-fixed release tags and 19.4.0 source; traced upload provenance, authentication order and reflected errors. Executed pinned Rack parser source and URI decoder methods in Ruby WASM. No complete GitLab instance was run and no external service was probed. Release and exploitation status checked through 2026-09-20 UTC.
SourceGitLab advisories, pinned release source and regression tests; Rack and URI source; CISA KEV; NVD; SOSEC local parser experiments
Evidence confidence High
7Evidence and sources
7.1Timeline
- Buffered uploads and form support
The request-body upload change and form parser enter source; the relevant path appears in the 18.7.0 tag.
- Emergency patches
GitLab releases 19.3.2, 19.2.6 and 19.1.8.
- KEV inclusion
CISA adds the vulnerability; watchTowr reports honeypot probes.
- 19.4 released
The new stable minor release includes the repair.
7.2Sources and material
- GitLab emergency patch advisory and detection linkshttps://docs.gitlab.com/releases/patches/patch-release-gitlab-19-3-2-released/
- GitLab fixing commit and regressionshttps://gitlab.com/gitlab-org/gitlab/-/commit/0ff7b6b2911723389f2271b10362591b0a69a166
- Rack 2.2.23 query parser sourcehttps://github.com/rack/rack/blob/f2af0c8f869193fa7bb7d20b619b3003418e1055/lib/rack/query_parser.rb
- URI 1.1.1 decoder sourcehttps://github.com/ruby/uri/blob/f1b05c89ab38667e7564896f994d4d6cfbc67149/lib/uri/common.rb
- GitLab: correlating response content and Workhorse logshttps://support.gitlab.com/hc/en-us/articles/30371082139164-Validating-whether-a-file-was-actually-exfiltrated-via-CVE-2026-85706-using-Workhorse-written-bytes
- CISA's official KEV datahttps://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
- NVD vulnerability recordhttps://nvd.nist.gov/vuln/detail/CVE-2026-85706
- watchTowr's September 11 probe observationshttps://watchtowr.com/intelligence/rapid-reaction-gitlab-critical-path-traversal-vulnerability-cve-2026-85706/
- GitLab 19.4 release noteshttps://docs.gitlab.com/releases/19/gitlab-19-4-released/