{"repro_id":"REPRO-2026-00354","version":12,"title":"GitLab CE/EE unauthenticated path traversal in Repository Commits API leads to arbitrary file read","repro_type":"security","status":"published","severity":"critical","description":"CVE-2026-85706 (CVSS 10.0, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) is an unauthenticated arbitrary file read vulnerability in GitLab CE/EE, reachable through the Repository Commits REST API area. VALIDATED MECHANISM (reproduced on gitlab/gitlab-ce:19.3.1-ce.0, Pruva run fb159526-1d3d-47e9-9cc3-1c963d6e0aad): an unauthenticated POST request whose URL carries a .json suffix plus URL-encoded path traversal (%2F, ..%2F) bypasses GitLab Workhorse route matching (workhorse matches on EscapedPath and fails to classify the request as a commits API route, forwarding the raw body upstream); Rails/Grape then strips the .json format suffix and the request reaches the commits endpoint, where an unconfined path parameter resolves outside the repository tree and leaks arbitrary files readable by the git/gitlab service account (/etc/passwd, gitlab-secrets.json, DB credentials, Gitaly/Praefect tokens, private repo content). CONFIRMED NON-MECHANISM (do NOT claim): plain GET /api/v4/projects/:id/repository/commits and GET /api/v4/projects/:id/repository/commits/:sha with traversal in :sha/ref_name is NOT vulnerable — those requests are blocked by GitLab's generic path traversal detection before reaching the endpoint (per run fb159526-1d3d-47e9-9cc3-1c963d6e0aad reasoning seq 464/771: 'GET does not file read, just commit lookup'; 'Claimed GET is blocked by generic path traversal detection before endpoint'). The first ticket revision claimed the GET/Gitaly-blob-resolution trigger (by analogy to CVE-2023-2825) — that was an unverified inference; avoid claiming specific parameter-level details not anchored in a public PoC or patch diff. Affected: all versions 18.7 before 19.1.8, 19.2 before 19.2.6, 19.3 before 19.3.2. Fixed in 19.1.8, 19.2.6, 19.3.2 (released 2026-09-10); fixed-version negative control confirmed blocked on 19.3.2. Reported by s3ntago via HackerOne. GitLab.com and Dedicated already patched. Reproduction hint: deploy gitlab/gitlab-ce:19.3.1-ce.0 (vulnerable) and 19.3.2-ce.0 (fixed) Docker images, create a project, send unauthenticated POST requests with .json-suffixed traversal URLs, compare file-leak response vs patched behavior. Exact vulnerable line and fixing diff are not yet public (GitLab private security mirror policy); treat Workhorse route-matching bypass + .json suffix as the validated entrypoint shape and prefer validated claims over inferred ones.","root_cause":"# RCA Report — CVE-2026-85706\n\n## Summary\n\nCVE-2026-85706 is an unauthenticated arbitrary local file read in GitLab CE/EE,\nreachable through the Repository Commits REST API. GitLab Workhorse classifies\nthe commits body-upload accelerator route with an **anchored regex on the clean\n(escaped) URI path**; appending a `.json` format suffix to\n`POST /api/v4/projects/:id/repository/commits` defeats that classification, so\nWorkhorse proxies the raw request to Rails with its signed\n`Gitlab-Workhorse` header. The Grape endpoint `post ':id/repository/commits'`\n(lib/api/commits.rb) calls `require_gitlab_workhorse!` but, in vulnerable\nversions, **never calls `authenticate!`**, and\n`API::Helpers::CommitsBodyUploaderHelper#file_params_from_body_upload`\n(lib/api/helpers/commits_body_uploader_helper.rb) takes the attacker-supplied\nflat request parameter `file.path` and uses it directly as a filesystem path:\n`File.exist?(params['file.path'])` followed by `File.read(file_path)`. When the\nrequest also carries a parameter literally named `Content-Type` with value\n`application/x-www-form-urlencoded`, the file content is fed to\n`Rack::Utils.parse_nested_query`, and any invalid percent-escape in the file\n(e.g. `%zz`) makes `Rack::QueryParser::InvalidParameterError` embed **the file\ncontent** in its message, which the vulnerable rescue clause echoes verbatim in\nthe HTTP 400 response body. Files without a parse-triggering byte are still\nconfirmed readable through an existence oracle (`local file not present` vs a\ndownstream 401/500). This was reproduced end-to-end on the real omnibus product\n(nginx → gitlab-workhorse → puma/Rails) with no sanitizers and no\nauthentication.\n\n## Impact\n\n- **Package/component affected**: GitLab CE/EE omnibus — `lib/api/commits.rb`\n  (Repository Commits API), `lib/api/helpers/commits_body_uploader_helper.rb`,\n  and the GitLab Workhorse body-upload route classification for\n  `/api/v4/projects/[^/]+/repository/commits`.\n- **Affected versions**: 18.7 before 19.1.8, 19.2 before 19.2.6, 19.3 before\n  19.3.2. Tested vulnerable: `gitlab/gitlab-ce:19.3.1-ce.0`\n  (image digest `sha256:f63df4c43029fe91db370609c0b40a1e3585cebd06e3e9637d93a9a3030eb86e`).\n  Tested fixed: `gitlab/gitlab-ce:19.3.2-ce.0`\n  (image digest `sha256:05453dd1d9aba27c2c487613141596868409b4d03247647f7d66cb0b36f321b8`).\n- **Risk level**: Critical (CVSS 3.1 10.0, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N).\n  Any unauthenticated network attacker who can reach the web endpoint and can\n  name an existing, anonymously routable project can read arbitrary files\n  readable by the `git` service account — including `/etc/gitlab/gitlab-secrets.json`\n  (demonstrated readable via the existence oracle: HTTP 500 downstream\n  processing vs `local file not present` for a missing file), database\n  credentials, Gitaly/Praefect tokens, and private repository content. With the\n  secrets file disclosed, an attacker can forge signed cookies/tokens\n  (`I:H` integrity impact in the CVSS vector).\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact**: unauthenticated arbitrary file read\n  (info leak of any file readable by the GitLab service account), critical\n  severity.\n- **Reproduced impact from this run**: full parity for the claimed read\n  primitive —\n  - **Full content echo, attacker-chosen path**: a canary file planted at\n    `/tmp/canary_85706_a1.txt` containing\n    `PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END` was read\n    unauthenticated and its complete content was reflected in the response:\n    `{\"message\":\"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END)\"}`.\n    Repeated with a distinct token in a second fresh-process attempt.\n  - **Existence oracle for arbitrary paths**: `/etc/passwd` (existing) proceeds\n    to downstream authorization (401), while a non-existent path returns\n    `400 ... local file not present` — confirming `File.exist?` is consulted on\n    the attacker-controlled path.\n  - **Sensitive-file readability**: `/etc/gitlab/gitlab-secrets.json` produced\n    a downstream HTTP 500 (content read and processed), not\n    `local file not present`, proving the secrets file is opened by the\n    vulnerable code path.\n  - **Operative bypass control**: the identical request **without** the\n    `.json` suffix returns `401 Unauthorized` on the vulnerable build —\n    Workhorse classifies that route and authentication is enforced — proving\n    the `.json` suffix is the operative route-matching bypass.\n  - **Fixed negative control**: the identical `.json`-suffixed request against\n    `19.3.2-ce.0` returns `401 Unauthorized` in both attempts (no echo, no\n    oracle) because `authenticate!` now runs before the upload handling.\n- **Parity**: `full` for the claimed info-leak impact.\n- **Not demonstrated**: post-read weaponization (e.g., forging signed requests\n  from `gitlab-secrets.json`) — out of scope for the filed claim, which is the\n  file-read primitive itself.\n\n## Root Cause\n\n1. **Missing authentication on a Workhorse-only endpoint** —\n   `lib/api/commits.rb` (v19.3.1), endpoint\n   `post ':id/repository/commits'`:\n   ```ruby\n   post ':id/repository/commits' do\n     require_gitlab_workhorse!\n     attrs = file_params_from_body_upload   # <-- reads a file BEFORE any authz\n     ...\n   ```\n   The endpoint trusts that the Workhorse body-upload middleware already\n   finalized the upload (the design assumption is that `file.path`/`file.size`\n   only ever come from Workhorse's signed multipart finalization), so it never\n   calls `authenticate!` and reads the raw Grape params.\n2. **Raw request parameter used as a filesystem path** —\n   `lib/api/helpers/commits_body_uploader_helper.rb` (v19.3.1):\n   ```ruby\n   def file_params_from_body_upload\n     file_path = params['file.path']\n     bad_request!('local file not present') unless File.exist?(file_path)\n     ...\n     elsif media_type == 'application/x-www-form-urlencoded'\n       Rack::Utils.parse_nested_query(File.read(file_path)).deep_symbolize_keys!\n     rescue Rack::QueryParser::InvalidParameterError => e\n       bad_request!(\"Invalid parameter: #{e.message}\")   # e.message embeds file content\n   ```\n   `params['file.path']`, `params['file.size']` and `params['Content-Type']`\n   are ordinary flat request parameters (Rack keeps `file.path` flat because\n   its nested-query syntax uses `file[path]`, not `file.path`), so a plain\n   URL query string controls the path that `File.exist?`/`File.read` open. The\n   `requires :file, type: WorkhorseFile` declaration is satisfied by a blank\n   `file=` parameter because `WorkhorseFile.parse` returns `nil` for blank\n   values.\n3. **Workhorse route-matching bypass** — Workhorse decides whether a request is\n   a commits body-upload (which it would intercept and finalize) by matching an\n   anchored regex against the *clean* (escaped) request path. The `.json`\n   format suffix makes the path not match the accelerator route, so Workhorse\n   simply proxies the raw request to Rails — while Rails/Grape *strips* the\n   `.json` suffix and routes it to the commits endpoint. Result: the endpoint\n   is reachable with Workhorse's signed header but without Workhorse's\n   upload finalization and without authentication.\n4. **Fix (v19.3.2)** — public tag diff `v19.3.1 → v19.3.2`:\n   - `authenticate!` added to `post ':id/repository/commits'` and to\n     `workhorse_authorize_commits_body_upload!` (\"Authenticate before\n     Workhorse buffers the request body to disk\").\n   - `file_params_from_body_upload` now trusts **only\n     middleware-finalized upload metadata**:\n     `uploaded_file = params[:file]; bad_request!('file is invalid') unless\n     uploaded_file.is_a?(::UploadedFile)`; path and size come from the\n     `UploadedFile` object, never raw params.\n   - Rescue clauses no longer echo `e.message`.\n\n## Reproduction Steps\n\n1. Reference: `bundle/repro/reproduction_steps.sh` (self-contained; executed\n   twice consecutively, both runs passing).\n2. What the script does:\n   - Pulls the immutable official images `gitlab/gitlab-ce:19.3.1-ce.0`\n     (vulnerable) and `gitlab/gitlab-ce:19.3.2-ce.0` (fixed) and records their\n     digests.\n   - Boots the real omnibus product (nginx → gitlab-workhorse → puma/Rails →\n     gitaly/postgresql/redis) in Docker, waits for a real HTTP health check\n     (`/users/sign_in` = 200, `/api/v4/version` responding).\n   - Captures in-container target binding: the shipped\n     `commits_body_uploader_helper.rb` (5 raw `file.path` references, 0\n     `authenticate` references on 19.3.1; `authenticate!` present on 19.3.2).\n   - Creates a publicly routable demo project through the real Rails service\n     (`gitlab-rails runner`), needed only so the URL routes.\n   - Sends the unauthenticated attacker request through the real HTTP\n     boundary:\n     `POST /api/v4/projects/1/repository/commits.json?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=<target>`\n     with header `Content-Type: application/x-www-form-urlencoded` and empty\n     body.\n   - Vulnerable build, attempt 1 (fresh boot): canary-with-`%zz` read (content\n     echo), `/etc/passwd` (existence oracle),\n     `/etc/gitlab/gitlab-secrets.json` (sensitive-file oracle), missing-file\n     control, and the no-`.json`-suffix control (401).\n   - Vulnerable build, attempt 2: `docker restart` for fresh processes, a\n     second distinct canary, plus controls.\n   - Fixed build, attempts 1 and 2 (fresh boot + restart): identical\n     `.json`-suffixed attack.\n3. Expected evidence of reproduction (all observed in this run):\n   - `repro/artifacts/http/vuln_attempt1_canary_response.txt` /\n     `vuln_attempt2_canary_response.txt`: HTTP 400 with\n     `Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_<per-attempt-token>_PCTBYTE_%zz_END)`\n     — arbitrary file content disclosure.\n   - `vuln_attempt1_missingfile_response.txt`: HTTP 400\n     `local file not present` (vs `/etc/passwd` 401, `gitlab-secrets.json`\n     500) — existence oracle.\n   - `vuln_attempt1_nosuffix_response.txt` / `vuln_attempt2_nosuffix_response.txt`:\n     HTTP 401 — the `.json` suffix is the operative bypass.\n   - `fixed_attempt1_canary_response.txt` / `fixed_attempt2_canary_response.txt`:\n     HTTP 401 `Unauthorized` — fixed version fails closed.\n\n## Evidence\n\n- Script + diagnostics: `bundle/repro/reproduction_steps.sh`,\n  `bundle/logs/reproduction_steps.log` (per-run diagnostic transcript).\n- Finalized per-request evidence (request URL, headers, status, body):\n  `bundle/repro/artifacts/http/*.txt`, SHA-256-bound in\n  `bundle/repro/runtime_manifest.json`.\n- Key excerpts (vulnerable 19.3.1, unauthenticated):\n  - Canary attempt 1 →\n    `{\"message\":\"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END)\"}`\n  - Canary attempt 2 (fresh processes) →\n    `{\"message\":\"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA2_5eed2badcafe_PCTBYTE_%zz_END)\"}`\n  - Missing file → `{\"message\":\"400 Bad request - local file not present\"}`\n  - `/etc/passwd` → `{\"message\":\"401 Unauthorized\"}` (read succeeded, parse\n    clean, downstream auth failure — existence oracle)\n  - `/etc/gitlab/gitlab-secrets.json` → `{\"message\":\"500 Internal Server Error\"}`\n    (secrets content read and processed downstream)\n  - Same request without `.json` → `{\"message\":\"401 Unauthorized\"}` (Workhorse\n    classifies the route)\n- Fixed 19.3.2, same attack → `{\"message\":\"401 Unauthorized\"}` (both attempts).\n- Environment: Docker (rootless) on Linux x86-64, 4 vCPU, 31 GB RAM; official\n  images as above; no sanitizers; product-mode proof through the real\n  nginx/workhorse/puma HTTP boundary.\n\n## Recommendations / Next Steps\n\n- **Upgrade** to GitLab 19.1.8 / 19.2.6 / 19.3.2 or later immediately; the\n  endpoint now authenticates before Workhorse buffers the body and only trusts\n  middleware-finalized `UploadedFile` metadata.\n- **Defense-in-depth**:\n  - Route/classification logic in proxies (Workhorse) and application routing\n    (Rails/Grape format-suffix stripping) must agree on path canonicalization;\n    anchored regexes on escaped paths should account for format suffixes.\n  - Never echo raw exception messages (`e.message`) to API clients.\n  - Parameters sourced from signed middleware handoff (e.g. `file.path`,\n    `file.size`) should be carried in a tamper-proof envelope, not re-accepted\n    from the raw request.\n- **Testing**: regression test that an unauthenticated\n  `POST /api/v4/projects/:id/repository/commits.json` with flat\n  `file.path`/`file.size`/`Content-Type` query parameters returns 401 before\n  any filesystem access, on both the accelerator-classified and non-classified\n  (suffixed) path shapes.\n\n## Additional Notes\n\n- **Idempotency**: the script removes prior containers at start, reuses pulled\n  images, truncates its diagnostic log per run, and re-creates the demo project\n  (idempotent `find_by(name:)`). It was executed twice consecutively with\n  identical CONFIRMED results.\n- **Limitations / edge cases**:\n  - Full content *echo* requires a parse-triggering byte in the target file\n    (invalid `%`-escape such as `%zz`, or invalid UTF-8). Benign-charset files\n    (e.g. stock `/etc/passwd`) still yield a reliable existence/readability\n    oracle (`local file not present` vs downstream 401/500); an attacker can\n    force echo for any file by causing downstream processing of parsed params,\n    or simply exploit the oracle.\n  - The exploit needs an existing, anonymously routable (public or internally\n    exposed) project id in the URL; no credentials of any kind are required.\n  - Raw `../` traversal inside the URL *path* is blocked by GitLab's generic\n    path-traversal middleware on both builds; the operative bypass is the\n    `.json` suffix on the route plus the `file.path` query parameter, exactly\n    as claimed.\n","cve_id":"CVE-2026-85706","cwe_id":"CWE-22","source_url":"https://about.gitlab.com/releases/2026/09/10/gitlab-19-3-2-released/","reproduced_at":"2026-09-12T11:32:06.839306+00:00","duration_secs":8331.417912,"tool_calls":181,"handoffs":2,"total_cost_usd":4.418722,"agent_costs":{"claim_matcher":0.024255,"judge":0.297675,"learning_policy":0.014651,"repro":1.529923,"support":0.022334,"vuln_variant":2.529884},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.024255},"judge":{"gpt-5.6-sol":0.297675},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.014651},"repro":{"accounts/fireworks/models/glm-5p3":1.529923},"support":{"accounts/fireworks/models/glm-5p3":0.022334},"vuln_variant":{"accounts/fireworks/models/glm-5p3":2.529884}},"vulnerable_version_variant_outcome":"found","fix_bypass_outcome":"not_found","variant_disclosure_state":"published","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-12T11:32:07.550954+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":13922,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":15778,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":16479,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":14617,"category":"reproduction_script"},{"path":"bundle/repro/artifacts/http/vuln_attempt2_missingfile_response.txt","filename":"vuln_attempt2_missingfile_response.txt","size":876,"category":"other"},{"path":"bundle/repro/artifacts/http/vuln_attempt2_nosuffix_response.txt","filename":"vuln_attempt2_nosuffix_response.txt","size":829,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":3295,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1868,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":101,"category":"other"},{"path":"bundle/logs/vuln_variant/matrix_results.json","filename":"matrix_results.json","size":576,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":3749,"category":"log"},{"path":"bundle/logs/vuln_variant/vulnerable_version.txt","filename":"vulnerable_version.txt","size":101,"category":"other"},{"path":"bundle/logs/vuln_variant/workhorse_regexes.txt","filename":"workhorse_regexes.txt","size":7622,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_t1_commits_json.txt","filename":"fixed_t1_commits_json.txt","size":842,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_t2_commits_slash.txt","filename":"fixed_t2_commits_slash.txt","size":839,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_t3_files_post_slash.txt","filename":"fixed_t3_files_post_slash.txt","size":850,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_t4_files_put_slash.txt","filename":"fixed_t4_files_put_slash.txt","size":847,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_t5_files_post_plain.txt","filename":"fixed_t5_files_post_plain.txt","size":849,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_t6_files_post_json.txt","filename":"fixed_t6_files_post_json.txt","size":853,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_t7_commits_authorize.txt","filename":"fixed_t7_commits_authorize.txt","size":857,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/fixed_t8_files_authorize.txt","filename":"fixed_t8_files_authorize.txt","size":863,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/target_binding_fixed.txt","filename":"target_binding_fixed.txt","size":2729,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/target_binding_vuln.txt","filename":"target_binding_vuln.txt","size":2450,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_t1_commits_json.txt","filename":"vuln_t1_commits_json.txt","size":934,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_t2_commits_slash.txt","filename":"vuln_t2_commits_slash.txt","size":931,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_t3_files_post_slash.txt","filename":"vuln_t3_files_post_slash.txt","size":942,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_t4_files_put_slash.txt","filename":"vuln_t4_files_put_slash.txt","size":939,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_t5_files_post_plain.txt","filename":"vuln_t5_files_post_plain.txt","size":867,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_t6_files_post_json.txt","filename":"vuln_t6_files_post_json.txt","size":871,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_t7_commits_authorize.txt","filename":"vuln_t7_commits_authorize.txt","size":945,"category":"other"},{"path":"bundle/vuln_variant/artifacts/http/vuln_t8_files_authorize.txt","filename":"vuln_t8_files_authorize.txt","size":951,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":9586,"category":"documentation"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":3840,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":3363,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1761,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":4360,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4743,"category":"other"}]}