# REPRO-2026-00354: GitLab CE/EE unauthenticated path traversal in Repository Commits API leads to arbitrary file read ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00354 CVE: CVE-2026-85706 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # RCA Report — CVE-2026-85706 ## Summary CVE-2026-85706 is an unauthenticated arbitrary local file read in GitLab CE/EE, reachable through the Repository Commits REST API. GitLab Workhorse classifies the commits body-upload accelerator route with an **anchored regex on the clean (escaped) URI path**; appending a `.json` format suffix to `POST /api/v4/projects/:id/repository/commits` defeats that classification, so Workhorse proxies the raw request to Rails with its signed `Gitlab-Workhorse` header. The Grape endpoint `post ':id/repository/commits'` (lib/api/commits.rb) calls `require_gitlab_workhorse!` but, in vulnerable versions, **never calls `authenticate!`**, and `API::Helpers::CommitsBodyUploaderHelper#file_params_from_body_upload` (lib/api/helpers/commits_body_uploader_helper.rb) takes the attacker-supplied flat request parameter `file.path` and uses it directly as a filesystem path: `File.exist?(params['file.path'])` followed by `File.read(file_path)`. When the request also carries a parameter literally named `Content-Type` with value `application/x-www-form-urlencoded`, the file content is fed to `Rack::Utils.parse_nested_query`, and any invalid percent-escape in the file (e.g. `%zz`) makes `Rack::QueryParser::InvalidParameterError` embed **the file content** in its message, which the vulnerable rescue clause echoes verbatim in the HTTP 400 response body. Files without a parse-triggering byte are still confirmed readable through an existence oracle (`local file not present` vs a downstream 401/500). This was reproduced end-to-end on the real omnibus product (nginx → gitlab-workhorse → puma/Rails) with no sanitizers and no authentication. ## Impact - **Package/component affected**: GitLab CE/EE omnibus — `lib/api/commits.rb` (Repository Commits API), `lib/api/helpers/commits_body_uploader_helper.rb`, and the GitLab Workhorse body-upload route classification for `/api/v4/projects/[^/]+/repository/commits`. - **Affected versions**: 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` (image digest `sha256:f63df4c43029fe91db370609c0b40a1e3585cebd06e3e9637d93a9a3030eb86e`). Tested fixed: `gitlab/gitlab-ce:19.3.2-ce.0` (image digest `sha256:05453dd1d9aba27c2c487613141596868409b4d03247647f7d66cb0b36f321b8`). - **Risk level**: Critical (CVSS 3.1 10.0, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N). Any unauthenticated network attacker who can reach the web endpoint and can name an existing, anonymously routable project can read arbitrary files readable by the `git` service account — including `/etc/gitlab/gitlab-secrets.json` (demonstrated readable via the existence oracle: HTTP 500 downstream processing vs `local file not present` for a missing file), database credentials, Gitaly/Praefect tokens, and private repository content. With the secrets file disclosed, an attacker can forge signed cookies/tokens (`I:H` integrity impact in the CVSS vector). ## Impact Parity - **Disclosed/claimed maximum impact**: unauthenticated arbitrary file read (info leak of any file readable by the GitLab service account), critical severity. - **Reproduced impact from this run**: full parity for the claimed read primitive — - **Full content echo, attacker-chosen path**: a canary file planted at `/tmp/canary_85706_a1.txt` containing `PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END` was read unauthenticated and its complete content was reflected in the response: `{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END)"}`. Repeated with a distinct token in a second fresh-process attempt. - **Existence oracle for arbitrary paths**: `/etc/passwd` (existing) proceeds to downstream authorization (401), while a non-existent path returns `400 ... local file not present` — confirming `File.exist?` is consulted on the attacker-controlled path. - **Sensitive-file readability**: `/etc/gitlab/gitlab-secrets.json` produced a downstream HTTP 500 (content read and processed), not `local file not present`, proving the secrets file is opened by the vulnerable code path. - **Operative bypass control**: the identical request **without** the `.json` suffix returns `401 Unauthorized` on the vulnerable build — Workhorse classifies that route and authentication is enforced — proving the `.json` suffix is the operative route-matching bypass. - **Fixed negative control**: the identical `.json`-suffixed request against `19.3.2-ce.0` returns `401 Unauthorized` in both attempts (no echo, no oracle) because `authenticate!` now runs before the upload handling. - **Parity**: `full` for the claimed info-leak impact. - **Not demonstrated**: post-read weaponization (e.g., forging signed requests from `gitlab-secrets.json`) — out of scope for the filed claim, which is the file-read primitive itself. ## Root Cause 1. **Missing authentication on a Workhorse-only endpoint** — `lib/api/commits.rb` (v19.3.1), endpoint `post ':id/repository/commits'`: ```ruby post ':id/repository/commits' do require_gitlab_workhorse! attrs = file_params_from_body_upload # <-- reads a file BEFORE any authz ... ``` The endpoint trusts that the Workhorse body-upload middleware already finalized the upload (the design assumption is that `file.path`/`file.size` only ever come from Workhorse's signed multipart finalization), so it never calls `authenticate!` and reads the raw Grape params. 2. **Raw request parameter used as a filesystem path** — `lib/api/helpers/commits_body_uploader_helper.rb` (v19.3.1): ```ruby 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 ``` `params['file.path']`, `params['file.size']` and `params['Content-Type']` are ordinary flat request parameters (Rack keeps `file.path` flat because its nested-query syntax uses `file[path]`, not `file.path`), so a plain URL query string controls the path that `File.exist?`/`File.read` open. The `requires :file, type: WorkhorseFile` declaration is satisfied by a blank `file=` parameter because `WorkhorseFile.parse` returns `nil` for blank values. 3. **Workhorse route-matching bypass** — Workhorse decides whether a request is a commits body-upload (which it would intercept and finalize) by matching an anchored regex against the *clean* (escaped) request path. The `.json` format suffix makes the path not match the accelerator route, so Workhorse simply proxies the raw request to Rails — while Rails/Grape *strips* the `.json` suffix and routes it to the commits endpoint. Result: the endpoint is reachable with Workhorse's signed header but without Workhorse's upload finalization and without authentication. 4. **Fix (v19.3.2)** — public tag diff `v19.3.1 → v19.3.2`: - `authenticate!` added to `post ':id/repository/commits'` and to `workhorse_authorize_commits_body_upload!` ("Authenticate before Workhorse buffers the request body to disk"). - `file_params_from_body_upload` now trusts **only middleware-finalized upload metadata**: `uploaded_file = params[:file]; bad_request!('file is invalid') unless uploaded_file.is_a?(::UploadedFile)`; path and size come from the `UploadedFile` object, never raw params. - Rescue clauses no longer echo `e.message`. ## Reproduction Steps 1. Reference: `bundle/repro/reproduction_steps.sh` (self-contained; executed twice consecutively, both runs passing). 2. What the script does: - Pulls the immutable official images `gitlab/gitlab-ce:19.3.1-ce.0` (vulnerable) and `gitlab/gitlab-ce:19.3.2-ce.0` (fixed) and records their digests. - Boots the real omnibus product (nginx → gitlab-workhorse → puma/Rails → gitaly/postgresql/redis) in Docker, waits for a real HTTP health check (`/users/sign_in` = 200, `/api/v4/version` responding). - Captures in-container target binding: the shipped `commits_body_uploader_helper.rb` (5 raw `file.path` references, 0 `authenticate` references on 19.3.1; `authenticate!` present on 19.3.2). - Creates a publicly routable demo project through the real Rails service (`gitlab-rails runner`), needed only so the URL routes. - Sends the unauthenticated attacker request through the real HTTP boundary: `POST /api/v4/projects/1/repository/commits.json?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=` with header `Content-Type: application/x-www-form-urlencoded` and empty body. - Vulnerable build, attempt 1 (fresh boot): canary-with-`%zz` read (content echo), `/etc/passwd` (existence oracle), `/etc/gitlab/gitlab-secrets.json` (sensitive-file oracle), missing-file control, and the no-`.json`-suffix control (401). - Vulnerable build, attempt 2: `docker restart` for fresh processes, a second distinct canary, plus controls. - Fixed build, attempts 1 and 2 (fresh boot + restart): identical `.json`-suffixed attack. 3. Expected evidence of reproduction (all observed in this run): - `repro/artifacts/http/vuln_attempt1_canary_response.txt` / `vuln_attempt2_canary_response.txt`: HTTP 400 with `Invalid parameter: invalid %-encoding (PRUVA85706_CANARY__PCTBYTE_%zz_END)` — arbitrary file content disclosure. - `vuln_attempt1_missingfile_response.txt`: HTTP 400 `local file not present` (vs `/etc/passwd` 401, `gitlab-secrets.json` 500) — existence oracle. - `vuln_attempt1_nosuffix_response.txt` / `vuln_attempt2_nosuffix_response.txt`: HTTP 401 — the `.json` suffix is the operative bypass. - `fixed_attempt1_canary_response.txt` / `fixed_attempt2_canary_response.txt`: HTTP 401 `Unauthorized` — fixed version fails closed. ## Evidence - Script + diagnostics: `bundle/repro/reproduction_steps.sh`, `bundle/logs/reproduction_steps.log` (per-run diagnostic transcript). - Finalized per-request evidence (request URL, headers, status, body): `bundle/repro/artifacts/http/*.txt`, SHA-256-bound in `bundle/repro/runtime_manifest.json`. - Key excerpts (vulnerable 19.3.1, unauthenticated): - Canary attempt 1 → `{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END)"}` - Canary attempt 2 (fresh processes) → `{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA2_5eed2badcafe_PCTBYTE_%zz_END)"}` - Missing file → `{"message":"400 Bad request - local file not present"}` - `/etc/passwd` → `{"message":"401 Unauthorized"}` (read succeeded, parse clean, downstream auth failure — existence oracle) - `/etc/gitlab/gitlab-secrets.json` → `{"message":"500 Internal Server Error"}` (secrets content read and processed downstream) - Same request without `.json` → `{"message":"401 Unauthorized"}` (Workhorse classifies the route) - Fixed 19.3.2, same attack → `{"message":"401 Unauthorized"}` (both attempts). - Environment: Docker (rootless) on Linux x86-64, 4 vCPU, 31 GB RAM; official images as above; no sanitizers; product-mode proof through the real nginx/workhorse/puma HTTP boundary. ## Recommendations / Next Steps - **Upgrade** to GitLab 19.1.8 / 19.2.6 / 19.3.2 or later immediately; the endpoint now authenticates before Workhorse buffers the body and only trusts middleware-finalized `UploadedFile` metadata. - **Defense-in-depth**: - Route/classification logic in proxies (Workhorse) and application routing (Rails/Grape format-suffix stripping) must agree on path canonicalization; anchored regexes on escaped paths should account for format suffixes. - Never echo raw exception messages (`e.message`) to API clients. - Parameters sourced from signed middleware handoff (e.g. `file.path`, `file.size`) should be carried in a tamper-proof envelope, not re-accepted from the raw request. - **Testing**: regression test that an unauthenticated `POST /api/v4/projects/:id/repository/commits.json` with flat `file.path`/`file.size`/`Content-Type` query parameters returns 401 before any filesystem access, on both the accelerator-classified and non-classified (suffixed) path shapes. ## Additional Notes - **Idempotency**: the script removes prior containers at start, reuses pulled images, truncates its diagnostic log per run, and re-creates the demo project (idempotent `find_by(name:)`). It was executed twice consecutively with identical CONFIRMED results. - **Limitations / edge cases**: - Full content *echo* requires a parse-triggering byte in the target file (invalid `%`-escape such as `%zz`, or invalid UTF-8). Benign-charset files (e.g. stock `/etc/passwd`) still yield a reliable existence/readability oracle (`local file not present` vs downstream 401/500); an attacker can force echo for any file by causing downstream processing of parsed params, or simply exploit the oracle. - The exploit needs an existing, anonymously routable (public or internally exposed) project id in the URL; no credentials of any kind are required. - Raw `../` traversal inside the URL *path* is blocked by GitLab's generic path-traversal middleware on both builds; the operative bypass is the `.json` suffix on the route plus the `file.path` query parameter, exactly as claimed. ## Reproduction Details Reproduced: 2026-09-12T11:32:06.839Z Duration: 8331 seconds Tool calls: 181 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00354 pruva-verify CVE-2026-85706 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00354&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00354/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-85706 - Source: https://about.gitlab.com/releases/2026/09/10/gitlab-19-3-2-released/ ## Artifacts - bundle/repro/rca_report.md (analysis, 13922 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 15778 bytes) - bundle/vuln_variant/rca_report.md (analysis, 16479 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 14617 bytes) - bundle/repro/artifacts/http/vuln_attempt2_missingfile_response.txt (other, 876 bytes) - bundle/repro/artifacts/http/vuln_attempt2_nosuffix_response.txt (other, 829 bytes) - bundle/repro/runtime_manifest.json (other, 3295 bytes) - bundle/repro/validation_verdict.json (other, 1868 bytes) - bundle/logs/vuln_variant/fixed_version.txt (other, 101 bytes) - bundle/logs/vuln_variant/matrix_results.json (other, 576 bytes) - bundle/logs/vuln_variant/reproduction_steps.log (log, 3749 bytes) - bundle/logs/vuln_variant/vulnerable_version.txt (other, 101 bytes) - bundle/logs/vuln_variant/workhorse_regexes.txt (other, 7622 bytes) - bundle/vuln_variant/artifacts/http/fixed_t1_commits_json.txt (other, 842 bytes) - bundle/vuln_variant/artifacts/http/fixed_t2_commits_slash.txt (other, 839 bytes) - bundle/vuln_variant/artifacts/http/fixed_t3_files_post_slash.txt (other, 850 bytes) - bundle/vuln_variant/artifacts/http/fixed_t4_files_put_slash.txt (other, 847 bytes) - bundle/vuln_variant/artifacts/http/fixed_t5_files_post_plain.txt (other, 849 bytes) - bundle/vuln_variant/artifacts/http/fixed_t6_files_post_json.txt (other, 853 bytes) - bundle/vuln_variant/artifacts/http/fixed_t7_commits_authorize.txt (other, 857 bytes) - bundle/vuln_variant/artifacts/http/fixed_t8_files_authorize.txt (other, 863 bytes) - bundle/vuln_variant/artifacts/http/target_binding_fixed.txt (other, 2729 bytes) - bundle/vuln_variant/artifacts/http/target_binding_vuln.txt (other, 2450 bytes) - bundle/vuln_variant/artifacts/http/vuln_t1_commits_json.txt (other, 934 bytes) - bundle/vuln_variant/artifacts/http/vuln_t2_commits_slash.txt (other, 931 bytes) - bundle/vuln_variant/artifacts/http/vuln_t3_files_post_slash.txt (other, 942 bytes) - bundle/vuln_variant/artifacts/http/vuln_t4_files_put_slash.txt (other, 939 bytes) - bundle/vuln_variant/artifacts/http/vuln_t5_files_post_plain.txt (other, 867 bytes) - bundle/vuln_variant/artifacts/http/vuln_t6_files_post_json.txt (other, 871 bytes) - bundle/vuln_variant/artifacts/http/vuln_t7_commits_authorize.txt (other, 945 bytes) - bundle/vuln_variant/artifacts/http/vuln_t8_files_authorize.txt (other, 951 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 9586 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 3840 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 3363 bytes) - bundle/vuln_variant/source_identity.json (other, 1761 bytes) - bundle/vuln_variant/validation_verdict.json (other, 4360 bytes) - bundle/vuln_variant/variant_manifest.json (other, 4743 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00354 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00354/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00354 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev