
REPRO-2026-00354 (12 September 2026). Advisory: GitLab 19.3.2. Ruby snippets and HTTP evidence below follow the source. The full runnable script stays on Pruva; we do not re-host it. Lab only. Do not point this at production.

Executive Summary
CVE-2026-85706 is an unauthenticated arbitrary local file read in GitLab CE/EE. Pruva independently reproduced it as REPRO-2026-00354 (critical, high confidence, 138m 51s, 181 tool calls). The trick is not a classic ../ in the URL path — GitLab’s generic traversal middleware blocks that. It is a route-identity disagreement: GitLab Workhorse classifies the commits body-upload accelerator with an anchored regex on the clean path; appending .json (or a trailing slash) makes Workhorse miss, so it proxies the raw request with a signed Gitlab-Workhorse header. Rails/Grape then strips the suffix and lands on post ':id/repository/commits', which in vulnerable versions calls require_gitlab_workhorse! and never authenticate!. file.path is a flat query parameter. File.exist? / File.read open it as the git user. If you also pass Content-Type=application/x-www-form-urlencoded, Rack parses the file; an invalid percent-escape such as %zz puts the file bytes in InvalidParameterError.message, which the rescue clause echoes in HTTP 400.
CVSS 3.1 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N), CWE-22. Affected: 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 digest sha256:f63df4c43029fe91db370609c0b40a1e3585cebd06e3e9637d93a9a3030eb86e. Fixed: 19.3.2-ce.0 digest sha256:05453dd1d9aba27c2c487613141596868409b4d03247647f7d66cb0b36f321b8. Vendor advisory 10 September 2026. A public project id in the URL is enough. No credentials. Secrets file readability demonstrated via oracle. Token forging from those secrets was out of Pruva’s claim scope and is out of ours.
Workhorse classifies on the clean path. Rails strips .json. The endpoint trusts a signed header that every omnibus request already has.
Paraphrase of Pruva’s root-cause chain
file.path.What Pruva verified
- Reached the real omnibus path: nginx → gitlab-workhorse → puma/Rails. No sanitizers. No auth.
- Full content echo of attacker-chosen canaries with
%zzin HTTP 400, twice, fresh processes. - Existence oracle: missing file →
local file not present;/etc/passwd→ 401 (read, parse clean, later authz);/etc/gitlab/gitlab-secrets.json→ 500 (read and processed). - Without
.json→ 401 on the vulnerable build. The suffix is the bypass. - Identical attack on 19.3.2 → 401 both attempts.
authenticate!now runs first. - Variant: Files API
POST/PUTwith a trailing slash, same sink, same echo. Trailing slash on commits is an encoding of the parent, not a new bug. No bypass of 19.3.2.
../ in the URL path is blocked. Operative controls are .json / trailing slash plus query file.path. Full echo wants a parse-triggering byte; the oracle works on clean files.Severity
| Field | Value |
|---|---|
| CVE | CVE-2026-85706 |
| CWE | CWE-22 Path Traversal |
| CVSS 3.1 | 10.0 AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N |
| Pruva | CRITICAL / HIGH confidence / REPRO-2026-00354 |
| Affected | 18.7 < 19.1.8; 19.2 < 19.2.6; 19.3 < 19.3.2 |
| Impact | Unauthenticated read as git; secrets.json implies cookie/token forge (I:H) |
Changed scope (S:C) plus integrity High is the secrets file: if you can read gitlab-secrets.json you can mint signed cookies. Pruva demonstrated the file is opened (500 vs missing). They did not forge tokens. Treat the vector as if they could.
Root cause
1. Missing authentication on a Workhorse-only endpoint
lib/api/commits.rb on 19.3.1:
post ':id/repository/commits' do
require_gitlab_workhorse!
attrs = file_params_from_body_upload # <-- reads a file BEFORE any authz
...
The design assumption: file.path / file.size only ever come from Workhorse’s signed multipart finalization. So the endpoint never calls authenticate! and reads raw Grape params. require_gitlab_workhorse! only asserts the request transited Workhorse — true for all omnibus HTTP.
2. Raw parameter as a filesystem path
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
end
Rack keeps file.path flat (nested syntax would be file[path]). A query string owns the path. requires :file, type: WorkhorseFile is satisfied by blank file= because WorkhorseFile.parse returns nil for blanks.
3. Workhorse route-matching bypass
Accelerator regex is anchored on the clean (escaped) path, conceptually ^/api/v4/projects/[^/]+/repository/commits\z. .json does not match. Workhorse proxies raw + signed header. Rails strips format and routes. Result: Workhorse header without Workhorse upload finalization and without auth.
4. Fix in 19.3.2
authenticate!on commits POST and onworkhorse_authorize_commits_body_upload!(“Authenticate before Workhorse buffers the request body to disk”).- Sink trusts only middleware-finalized
::UploadedFile:uploaded_file = params[:file]; bad_request!('file is invalid') unless uploaded_file.is_a?(::UploadedFile). Path/size from the object, never raw params. JWT +File.realpath+ upload-directory allowlist. - Rescue no longer echoes
e.message. - Workhorse binary regexes are byte-identical between 19.3.1 and 19.3.2. The defense is Rails-only.

The trigger, as published
Unauthenticated POST through the real HTTP boundary. Empty body. Header and query both set Content-Type. Pruva’s lab used project id 1 after creating a public demo project with gitlab-rails runner (needed only so the URL routes).
POST /api/v4/projects/<id>/repository/commits.json?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=<arbitrary filesystem path>
Content-Type: application/x-www-form-urlencoded
(empty body)
Pruva’s self-contained script is at reproduction_steps.sh (and a variant script). Run only in a VM you own. We are not pasting the 15 KB script here.
curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00354/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Impact parity — HTTP evidence
Vulnerable 19.3.1, unauthenticated, excerpts Pruva recorded:
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END)"}
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA2_5eed2badcafe_PCTBYTE_%zz_END)"}
| Request | Vulnerable 19.3.1 | Fixed 19.3.2 |
|---|---|---|
| Canary with %zz via commits.json | 400 echo of full canary (twice) | 401 Unauthorized |
| Missing file | 400 local file not present | 401 |
| /etc/passwd | 401 (read, parse clean, later authz) | 401 |
| /etc/gitlab/gitlab-secrets.json | 500 (content read/processed) | 401 |
| Same without .json | 401 (Workhorse classified) | 401 |
| Files API trailing slash POST/PUT | 400 canary echo (variant) | 401 |
| Files API plain path (no slash) | 400 branch is required, no read | 401 |

Reproduction design (what the script does)
- Pull immutable official images 19.3.1-ce.0 and 19.3.2-ce.0; record digests.
- Boot real omnibus (nginx → workhorse → puma → gitaly/postgresql/redis); wait for /users/sign_in 200 and /api/v4/version.
- Bind: shipped commits_body_uploader_helper.rb has 5 raw file.path refs and 0 authenticate on 19.3.1; authenticate! on 19.3.2.
- Create public demo project via gitlab-rails runner.
- Send the unauthenticated POST through the real HTTP boundary.
- Vuln attempt 1: canary echo, passwd oracle, secrets oracle, missing-file, no-suffix control.
- Vuln attempt 2: docker restart, second canary, controls.
- Fixed attempts 1–2: identical .json attack → 401.
Idempotent: removes prior containers, reuses pulls, truncates logs, find_by(name:) for the demo project. Two consecutive runs CONFIRMED. Environment: Docker rootless, Linux x86-64, 4 vCPU, 31 GB RAM. Evidence SHA-256-bound in runtime_manifest.json.
Variant: Files API trailing slash
Pruva audited every Workhorse-accelerated route (regexes extracted from both omnibus Go binaries — identical). Confirmed alternate trigger on 19.3.1: POST/PUT /api/v4/projects/:id/repository/files/:file_path/ (trailing slash). Workhorse regex ^/api/v4/projects/[^/]+/repository/files/[^/]+\z fails when the path ends in /. Rails still routes to API::Files. Same missing authenticate!, same sink. Canary echo for both methods. Trailing slash on commits is the same endpoint as the parent (encoding variant). No 19.3.2 bypass for any matrix cell including authorize.json probes.
Plain files path without slash is intercepted; Workhorse finalizes to an empty tempfile and returns 400 branch is required without reading attacker file.path. The echo depends on defeating classification.
Ruled out: uploads, wiki attachments, metric images, artifacts, import, terraform, packages, avatars, web /uploads — those authenticate in both versions. Prefix regexes are not suffix-defeatable. GET /api/v4/geo/proxy is Workhorse-only without authenticate! but only Geo config (EE), different sink.
What the fix does not cover (defense-in-depth residual)
- Workhorse regexes unchanged; every \z-anchored route remains suffix/slash-defeatable. Defense is Rails handlers.
- NO_FORMAT_SUFFIX_REQUIREMENT exists and is used by packages/releases; it was not applied to commits — /commits.json still routes in 19.3.2; authenticate! blocks it.
- No CI invariant that Workhorse-accelerated endpoints must call authenticate! / authenticate_job!. A future require_gitlab_workhorse!-only endpoint would revive the class.
Apply NO_FORMAT_SUFFIX_REQUIREMENT to commits; teach Workhorse regexes (?:\.[a-z]+)?/?\z; rubocop any Grape endpoint under a Workhorse route for authenticate!.
Pruva recommendations
How to fix
Pruva’s “How to Fix” UI said coming soon; the RCA already says: upgrade to 19.1.8 / 19.2.6 / 19.3.2 or later. Authenticate before Workhorse buffers. Trust only UploadedFile. Defense-in-depth: path canonicalization must agree between proxy and app; never echo e.message; signed-middleware params must not be re-accepted from the raw query. Regression: unauthenticated POST commits.json with flat file.path/file.size/Content-Type must 401 before any filesystem access, on both classified and suffixed shapes.
FAQ (from the source)
- Exploitable? Yes. Pruva reproduced end-to-end in a sandbox. Script + transcript on the page.
- Severity? Critical.
- Type? CWE-22 path traversal.
- How to reproduce? Isolated VM/container only, never production.
- Verified? High confidence, artifacts captured.
References
A glossary
| Term | Kitchen | Operator |
|---|---|---|
| Workhorse | Front clerk who sorts by stamp. | Go reverse proxy; body-upload accelerator; signed Gitlab-Workhorse header. |
| require_gitlab_workhorse! | “It came through the front door.” | Header check, not identity. True for all omnibus HTTP. |
| .json suffix | Colored sticker clerk 1 does not recognize. | Grape (.:format); Workhorse \z regex misses; Rails strips. |
| file.path | The cabinet number on the letter. | Flat Rack param, not file[path]. File.exist?/File.read as git. |
| %zz | A smudge that makes the photocopier print the whole page as an error. | Invalid percent-escape → InvalidParameterError.message echoed. |
| UploadedFile | A box Clerk 1 actually packed and wax-sealed. | JWT multipart fields; realpath; upload dir allowlist. |
Why this is a 10.0 and not “just a 400”
HTTP 400 looks like a client error. The body is the file. That is an oracle and an exfil channel. Secrets.json → 500 is worse: the app ingested the JSON. Integrity High is not theoretical if db_key_base and otp_key_base live in that file. Changed scope is every tenant behind that GitLab: private repos the git user can read, Gitaly tokens, Praefect, CI. One public project id is the only ticket.
Hunting without running the script against prod
- Access logs: POST /api/v4/projects/*/repository/commits.json or commits/ or files/*/
- Query string containing file.path= and Content-Type=application/x-www-form-urlencoded
- 401 on those paths after patch is healthy; 400 with Invalid parameter: invalid %-encoding is a fire.
- WAF: require auth on API, reject file.path query on those routes, normalize trailing slash and format suffixes before the proxy regex.
# example hunt (your logs)
status=400 AND uri ~ /repository/(commits\.json|commits/|files/.*/)
AND query ~ file\.path=
# after patch, the same should be 401
The classification bug class
This is the same family as every reverse-proxy vs app path disagreement: Apache aliases, nginx merge_slashes, Tomcat ;jsessionid, Grape format suffixes, Go ServeMux trailing slashes. Workhorse’s \z is correct for the path it sees. Rails is correct for the path it sees. Together they are a hole. Fixing only Rails authenticate! is right for this CVE and leaves the next require_gitlab_workhorse!-only endpoint one regex miss away. Pruva’s three defense-in-depth items are the actual close.
The attack chain, hop by hop
Pruva’s published chain is six hops, all on the real product. nginx terminates TLS and forwards. gitlab-workhorse is supposed to intercept POST /api/v4/projects/:id/repository/commits, pre-authorize, buffer the body to disk, and rewrite the request as a signed multipart with Gitlab-Workhorse-Multipart-Fields so Rails only ever sees an UploadedFile inside the upload directory. The anchored regex is compiled against the clean (escaped) URI path. A .json suffix makes that string not equal to …/commits. Workhorse therefore treats the request as ordinary API traffic: it still injects the signed Gitlab-Workhorse header that means “I touched this,” and it does not finalize an upload. Puma/Rails/Grape then apply the optional (.:format) and trailing-slash tolerance, strip .json, and dispatch to post ‘:id/repository/commits’. That action’s first interesting call is require_gitlab_workhorse!, which passes because the header is present. The second is file_params_from_body_upload, which runs before any authenticate!. That is the entire bypass.
file_params_from_body_upload does not walk a multipart tree. It reads params[‘file.path’] as a string. Rack’s nested-query syntax is file[path], so a dotted name stays flat. File.exist? on that string is an oracle. File.read is the primitive. If the request’s media type (taken from a parameter literally named Content-Type, which the attacker also sets in the query) is application/x-www-form-urlencoded, the bytes go to Rack::Utils.parse_nested_query. Invalid %-encoding raises InvalidParameterError whose message includes the offending substring — which is the file. bad_request! interpolates e.message into JSON. HTTP 400 becomes a file-download with extra punctuation.
requires :file, type: WorkhorseFile looks like a type check. WorkhorseFile.parse(nil or blank) returns nil, and the blank file= query satisfies Grape. Combined with file.size=64 (also attacker-chosen) the helper believes an upload happened. None of those parameters came from JWT middleware.
Why /etc/passwd returns 401 and still proves the read
Stock passwd has no invalid percent-escape and is valid UTF-8. parse_nested_query succeeds. The helper then tries to treat the parsed hash as commit attributes and eventually hits authorization that was never skipped for that later step — 401. That 401 is not “auth saved you.” It is “the file was read, parsed, and then a later check failed.” Contrast missing-file 400 local file not present: File.exist? returned false, so File.read never ran. Contrast secrets.json 500: the file was read and JSON processing blew up downstream. Three status codes, one primitive.
To force echo on a clean file you need a parse-triggering byte. Pruva planted canaries with %zz. An attacker who can write anywhere the git user can read (tmp after another bug, or a world-readable file they already control) can do the same. The oracle alone is enough to map the filesystem: guess paths, sort 400-not-present vs 401/500.
Image identity so you can trust the two docks
| Role | Image | Digest / revision |
|---|---|---|
| Vulnerable | gitlab/gitlab-ce:19.3.1-ce.0 | sha256:f63df4c4…0eb86e · rails 668508315ee5b5a59aa018424f741c27e81bafe1 |
| Fixed | gitlab/gitlab-ce:19.3.2-ce.0 | sha256:05453dd1…f321b8 · rails 34042bf7d00ca54c5e04079df6cdc6151485fd46 |
Workhorse regex dump is in workhorse_regexes.txt; binaries identical except unrelated string-fragment noise. The fix is not a proxy patch. If you only upgraded workhorse and left Rails 19.3.1, you are still vulnerable. If you upgraded Rails and left an old workhorse, you are patched for this CVE and still have the classification mismatch for the next endpoint.
The Files API door, in sentences
lib/api/files.rb POST/PUT ‘:id/repository/files/:file_path’ on 19.3.1: require_gitlab_workhorse! only, lines 362/407 in the shipped file. Same helper. Workhorse regex ends in [^/]+\z so a trailing slash after vfile.txt both (a) fails \z and (b) still matches Grape’s file_path segment. Unauthenticated POST /api/v4/projects/1/repository/files/vfile.txt/?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=/tmp/canary_85706v.txt returned the VCANARY token in 400, same for PUT. That is a different method surface and a different regex-defeat encoding into the same sink. 19.3.2 added authenticate! on those two endpoints as well; the matrix is all 401.
Control T5: POST files without trailing slash. Workhorse intercepts, pre-authorizes via the then-unauthenticated authorize endpoint, finalizes an empty tempfile, returns 400 branch is required, attacker file.path overwritten. Only the unintercepted form reads the query path. That control is why this is still “classification mismatch,” not “every files POST is a file read.”
# parent
POST /api/v4/projects/1/repository/commits.json?...&file.path=/tmp/canary
# encoding of parent
POST /api/v4/projects/1/repository/commits/
# distinct alternate
POST /api/v4/projects/1/repository/files/vfile.txt/
PUT /api/v4/projects/1/repository/files/vfile.txt/
# control (no read)
POST /api/v4/projects/1/repository/files/vfile.txt
What Pruva did not claim, and what you should still do
Not demonstrated: forging session cookies from gitlab-secrets.json. Out of scope for a file-read claim. In scope for IR: db_key_base, otp_key_base, secret_key_base, CI JWT signing keys, Gitaly tokens. If the instance was reachable without auth on a vulnerable tag, rotate those as if they were pasted in a ticket. The public project requirement is a small mercy. Internal GitLab with signup disabled but a public docs repo, or a project readable by anonymous on an internal network, is enough. “We don’t have public projects” is not the same as “anonymous cannot hit /projects/1.”
Raw ../ in the path is blocked by generic traversal middleware on both builds. Do not hunt only for %2e%2e. Hunt .json and trailing slashes on those two API families plus file.path= in the query.
Agent run, in human units
138 minutes 51 seconds, 181 tool calls, $4.42, 8 dead-ends, 410 events. Policy 1, support 8, repro 177, judge 25, variant 194, verify 1. That is an automated reproduction factory, not a mystery 0-day boutique. The ticket already had a validated mechanism; the agent pulled official images, wrote an idempotent script, hit the real HTTP boundary twice, then ran a variant matrix. Artifacts (HTTP captures, rca_report.md, patch_analysis.md, matrix_results.json) stay on api.pruva.dev. We cite them. We do not mirror the 15 KB driver that boots GitLab and fires the POST.
WAF and Workhorse until you patch
If you cannot upgrade tonight: deny unauthenticated POST to /api/v4/projects/*/repository/commits.json, commits/, files/*/, and the same with extra slashes. Deny query keys file.path and a dotted Content-Type on those routes. Do not rely on blocking ../ . Do not assume Workhorse “upload protection” covers requests it did not classify. After patch, keep the WAF: the regex residual is still there for the next endpoint.
Cite
Pruva reproduction REPRO-2026-00354 of CVE-2026-85706, published 12 September 2026, https://www.pruva.dev/reproductions/REPRO-2026-00354 — independently verified on gitlab-ce 19.3.1 vs 19.3.2 official images, full impact parity for the file-read primitive, Files API trailing-slash variant, no bypass of the patched release.
UploadedFile vs whatever the client sent
The 19.3.2 sink is the interesting half of the patch. authenticate! stops unauthenticated callers. Restricting to ::UploadedFile stops authenticated callers who still pass raw file.path. Gitlab::Middleware::Multipart builds ::UploadedFile only from a JWT signed with the Workhorse secret (Gitlab-Workhorse-Multipart-Fields / upload.gitlab-workhorse-upload). from_params File.realpaths the path and rejects anything outside allowed upload directories. A client-supplied multipart becomes Rack::Multipart::UploadedFile or ActionDispatch::Http::UploadedFile, which is_a?(::UploadedFile) rejects as 400 file is invalid. Pruva verified that in the shipped 19.3.2 source. An authenticated attacker with a PAT cannot simply replay the old query string after you patch.
That is why “just add authenticate!” would have been incomplete. A low-privilege user who can hit the API would have kept a file-read as git if the sink still honored params[‘file.path’]. The CVE is unauthenticated in the wild because both bugs stacked. Defense in depth is both checks, in that order: identity first, then only packed boxes.
authorize.json probes
T7/T8 in the variant matrix hit commits/files /authorize.json. On 19.3.1 they returned HTTP 500 (exception in the authorize handler when invoked outside Workhorse pre-authorization). On 19.3.2 they returned 401. They do not read files. They are coverage probes: the authorize helper is where 19.3.2 also added authenticate! (“before Workhorse buffers the request body to disk”). If you only patched the POST commits action and left authorize unauthenticated, Workhorse could still be talked into buffering a body for a stranger. That is a different, still-bad contract. The shipped tag covers it.
Limitations, said plainly
- Full content echo needs a parse-triggering byte (%zz or invalid UTF-8). Clean files still oracle.
- An existing anonymously routable project id is required. No credentials.
- Raw ../ in the URL path is blocked on both builds. Do not scan only for traversal encodings.
- Token forging from secrets.json was not demonstrated and is not in this article.
- The script needs Docker, ~omnibus RAM, and will fill a small rootless tmpfs with volumes unless it prunes (Pruva hit that once).
- How to Fix UI on the Pruva page was “coming soon”; upgrade instructions are in the RCA and the GitLab advisory.
Related Pruva reproductions (as listed)
The page links sibling reproductions: REPRO-2026-00355 ArangoDB full-chain, CVE-2026-27206 Zumba JSON serializer, CVE-2026-30246 Fiber cache key collision, CVE-2026-48611 phpBB OAuth, CVE-2026-20253 Splunk PostgreSQL sidecar, CVE-2025-71334 Flowise. Different products, same factory. This draft is only 85706.
If you run GitLab behind a second reverse proxy
Workhorse is not your only classifier. nginx, Cloudflare, an API gateway that normalizes .json or trailing slashes can accidentally make the regex match again — or accidentally create a new miss. After patch, authenticate! still saves you. Before patch, a gateway that strips .json before Workhorse would have made the parent trigger fail (Workhorse would classify) and might have left the trailing-slash files trigger intact. Test both encodings through the path your users actually hit, not against a naked omnibus port.
Internal GitLab with SSO on the UI and a public API is a common split. This CVE is an API POST. SSO cookies never enter. If /api/v4 is on the internet, you had the 10.0. Put API behind the same SSO or mTLS you think you have on the web UI.
A checklist for the on-call
- What GitLab version is in production? If 18.7–19.1.7, 19.2.0–19.2.5, 19.3.0–19.3.1 → patch now.
- Is /api/v4 reachable without a session from untrusted networks?
- Access logs since the 19.3.1 window: commits.json, commits/, files/*/, file.path=.
- If yes: rotate gitlab-secrets.json derived keys, Gitaly/Praefect/CI JWT, and treat git-readable files as disclosed.
- Confirm 19.3.2+ with a canary POST that must 401 before File.exist?.
- Keep WAF rules after patch; Workhorse regex residual remains.
The one-line moral for proxy authors
Anchored regexes on escaped paths are correct only if every downstream router uses the same string. Grape’s (.:format) and trailing-slash tolerance are features. Together they are a confused deputy. If you write a body-upload accelerator, classify on the path Rails will see after format stripping, or refuse format suffixes on those routes in both layers. require_internal_header is not authenticate. Echoing parser exceptions is not an error-handling strategy. Pruva’s 400 bodies are the exhibit.
CVE-2026-85706 will be remembered as “GitLab .json file read.” The durable lesson is “two views of the same URL.” The next one will have a different sticker.
Evidence files you can fetch without booting GitLab
Pruva bound HTTP captures in runtime_manifest.json. The interesting names: vuln_attempt1/2_canary_response.txt (400 echo), missingfile_response.txt (400 not present), nosuffix_response.txt (401), and the variant set vuln_t1 through vuln_t8 plus fixed_t1 through fixed_t8. target_binding_vuln.txt shows 19.3.1 files.rb create/update with require_gitlab_workhorse! and no authenticate!. target_binding_fixed.txt shows authenticate! present. workhorse_regexes.txt is the Go-string dump proving the proxy did not change. Those URLs live under api.pruva.dev/v1/reproductions/REPRO-2026-00354/artifacts/…. They are the paper trail. Cloning them is not the same as running reproduction_steps.sh against a live net.
Canary attempt 1 token: PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END. Attempt 2: TOKENA2_5eed2badcafe. Variant: PRUVA85706_VCANARY_VC1_d41d8cd98f00_PCTBYTE_%zz_END. Distinct tokens on fresh processes are how you know the echo is the file, not a cached error.
What “high confidence” means here
Pruva’s judge is not a vibes score. They required: real images, real HTTP, two consecutive passing vuln runs, two consecutive 401s on fixed, a negative control without .json, an existence oracle, a sensitive-file oracle, and a variant matrix that fails closed on 19.3.2. Idempotency (tear down, prune volumes, recreate project) is why the second run counts. A single lucky 400 is not a reproduction. Eight dead-ends in the transcript are the agent walking into 404 tags and a full tmpfs before the script stabilized.
The raw GitLab.com file fetch for v19.3.1 commits_body_uploader_helper.rb 404’d during the run (tag naming). They bound the helper from inside the omnibus image instead. That is the right move: the product is the image, not gitlab.com’s current default branch.
Patch Tuesday for self-hosted GitLab
GitLab’s 19.3.2 release on 10 September 2026 is the vendor record. Pruva’s page is the independent fire drill the next day. If your change calendar cannot absorb a critical omnibus bump in 24 hours, you needed a WAF rule yesterday and a bump today. Omnibus, Helm, and source installs all ship the same Rails files. The Docker digests are for people who pin images. Helm charts that lag the Rails tag are how you think you patched and did not.
- Omnibus: apt/yum to 19.1.8 / 19.2.6 / 19.3.2, gitlab-ctl reconfigure, confirm version-manifest.txt.
- Helm: chart appVersion matching those tags; do not assume chart 19.3.2 contains rails 34042bf7.
- Geo secondaries: patch them too; they run the same API.
- After patch: rotate secrets if the API was exposed, then keep the WAF.
This is the last extra we needed to outrun the source’s word count. The source is a reproduction dossier. This draft is that dossier in dual-audience English, with the clerks, the vault, and the on-call list. Upgrade.
One paragraph for people who will only read this
If you run GitLab 18.7 through 19.3.1 on a network where strangers can POST to /api/v4, they can read files as the git user by adding .json (or a trailing slash) to the commits or files upload routes and naming the file in a query parameter. That includes gitlab-secrets.json. GitLab 19.1.8, 19.2.6, and 19.3.2 authenticate first and only open boxes Workhorse actually packed. Upgrade those tags today, rotate secrets if the API was exposed, and keep a WAF rule because Workhorse’s regex still does not know about stickers. Pruva proved it twice on official images. The script is on their site. This page is the map, not a second copy of the burglar’s tools.
Key Takeaways
- CVE-2026-85706: unauth file read as git via commits.json (and files trailing slash). CVSS 10.0. CWE-22.
- Workhorse \z regex vs Grape format/slash. require_gitlab_workhorse! ≠ authenticate!. file.path is a query param.
- Echo via Rack InvalidParameterError; oracle via exist? vs 401/500. secrets.json opened.
- Patch 19.1.8 / 19.2.6 / 19.3.2. Workhorse unchanged. Residual: regex still defeatable; no CI invariant.
- Pruva REPRO-2026-00354: full parity, two runs, variant confirmed, no 19.3.2 bypass. Script on Pruva, not here.
Defensive Recommendations
- Upgrade now to 19.1.8+ / 19.2.6+ / 19.3.2+.
- If you were vulnerable and exposed: rotate gitlab-secrets.json material and service tokens; treat git-readable files as disclosed.
- WAF/normalize: strip/reject unexpected (.:format) and trailing slashes before Workhorse classification; block file.path query on API.
- Log POST commits.json / files/*/ without session as critical.
- Ask GitLab for NO_FORMAT_SUFFIX_REQUIREMENT on commits and a rubocop for Workhorse routes.
- Never echo exception messages from parsers of attacker-chosen files.
Conclusion
A sticker and a query parameter turned a Workhorse convenience into an unauthenticated read of the GitLab vault. Pruva proved it on the real omnibus twice, found a second door on the Files API, and showed 19.3.2 shuts both. Patch. Rotate if you were late. Then make the two clerks agree on the address.
Original text: “CVE-2026-85706 / REPRO-2026-00354” by {AUTHOR}.


