From 3086f5addb03aa767284f31356e14f005b4dd86f Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 7 Aug 2026 12:19:24 -0400 Subject: [PATCH] fix(btcpay): pin 2.4.2 (actively exploited 2FA bypass); teach drift check the release catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BTCPay 2.4.2 fixes a critical vulnerability that upstream reports as actively exploited: a TOTP two-factor bypass via Greenfield Basic authentication (btcpayserver/btcpayserver#7491). Checked the 2.3.9 -> 2.4.2 breaking changes against how Archipelago actually configures BTCPay; both are inert here: - 2.4.0 removed the LNBank and Lightning Charge backends. Ours is a direct LND connection built by container::lnd::ensure_btcpay_lnd_connection_secret. - 2.4.2 disables Greenfield Basic auth five minutes after account creation. Nothing in the daemon or frontend consumes BTCPay's API. The manifest bump alone does NOT reach nodes: catalog_image_override makes the signed catalog authoritative whenever the image repo matches, so a node would be forced back to 2.3.9. The catalog edit is held locally until the signing ceremony runs, because an unsigned catalog published to main would be accepted by nodes (absent signatures are allowed) and would quietly drop authenticity. check-app-catalog-drift.py only understood app-catalog/catalog.json, where `apps` is a list. releases/app-catalog.json — the SIGNED catalog nodes actually resolve apps through — keys `apps` by id and wraps each app's full manifest under manifest.app. So the checker parsed the file that governs nothing and raised ValueError on the file that governs everything. It now reads both shapes. Running it against the release catalog shows the repo and the catalog agree on content: of 34 image differences, all 34 are the registry host alone and every tag is identical. The remaining version-string drift (v1.18.0 vs 1.18.0, 1.30.0-alpine vs 1.30.0) is cosmetic metadata, not image drift. Co-Authored-By: Claude Opus 5 (1M context) --- apps/btcpay-server/manifest.yml | 4 +-- scripts/check-app-catalog-drift.py | 43 +++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/btcpay-server/manifest.yml b/apps/btcpay-server/manifest.yml index 703a9f0c..705feed1 100644 --- a/apps/btcpay-server/manifest.yml +++ b/apps/btcpay-server/manifest.yml @@ -1,11 +1,11 @@ app: id: btcpay-server name: BTCPay Server - version: 2.3.9 + version: 2.4.2 description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries. container: - image: docker.io/btcpayserver/btcpayserver:2.3.9 + image: docker.io/btcpayserver/btcpayserver:2.4.2 pull_policy: if-not-present network: archy-net secret_env: diff --git a/scripts/check-app-catalog-drift.py b/scripts/check-app-catalog-drift.py index afe59b96..0d943ece 100644 --- a/scripts/check-app-catalog-drift.py +++ b/scripts/check-app-catalog-drift.py @@ -52,12 +52,49 @@ LEGACY_STACK_CATALOG_IDS = { def load_catalog(path: Path) -> dict[str, dict[str, Any]]: + """Load either catalog shape into {app_id: app-fields}. + + Two formats exist and only one used to be understood here: + + * app-catalog/catalog.json — `apps` is a LIST of entries carrying `id`. + * releases/app-catalog.json — `apps` is a DICT keyed by app id, and each + entry wraps the app's full manifest under `manifest.app` (the signed + release catalog; EMBED_MANIFESTS has been on since 2026-06-23). + + The signed release catalog is the one nodes actually resolve apps through, + so a drift checker that only parsed the list form was checking the file + that governs nothing and crashing on the file that governs everything. + """ with path.open("r", encoding="utf-8") as fh: data = json.load(fh) apps = data.get("apps", []) - if not isinstance(apps, list): - raise ValueError(f"{path}: expected .apps to be a list") - return {str(app.get("id", "")): app for app in apps if isinstance(app, dict) and app.get("id")} + + if isinstance(apps, list): + return { + str(app.get("id", "")): app + for app in apps + if isinstance(app, dict) and app.get("id") + } + + if isinstance(apps, dict): + out: dict[str, dict[str, Any]] = {} + for app_id, entry in apps.items(): + if not isinstance(entry, dict): + continue + manifest = entry.get("manifest") + if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict): + # Embedded manifest: compare against the same fields the disk + # manifests expose, plus the entry's own version. + app = dict(manifest["app"]) + else: + app = {} + app.setdefault("id", app_id) + if entry.get("version") is not None: + app["version"] = entry["version"] + out[str(app_id)] = app + return out + + raise ValueError(f"{path}: expected .apps to be a list or an object") def load_manifests(apps_dir: Path) -> dict[str, dict[str, Any]]: