From b3e8e437367de610230f2f6c05763b3f74979991 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 12 Nov 2025 17:56:16 +0000 Subject: [PATCH] Related to #698: harden installer release detection --- install.sh | 51 +++++++++++++++--- scripts/validate-published-release.sh | 76 +++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) create mode 100755 scripts/validate-published-release.sh diff --git a/install.sh b/install.sh index 4525c22..5dc1637 100755 --- a/install.sh +++ b/install.sh @@ -36,6 +36,41 @@ DETECTED_CTID="" DEBIAN_TEMPLATE_FALLBACK="debian-12-standard_12.12-1_amd64.tar.zst" DEBIAN_TEMPLATE="" +get_latest_release_from_redirect() { + # Follow the GitHub "latest" redirect and extract the tag in a way that + # tolerates intermediate redirects that omit /tag/ (issue #698). + local target_url="${1:-https://github.com/$GITHUB_REPO/releases/latest}" + local effective_url="" + local curl_cmd=(curl -fsSL --connect-timeout 5 --max-time 10 -o /dev/null -w '%{url_effective}' "$target_url") + + if command -v timeout >/dev/null 2>&1; then + effective_url=$(timeout 10 "${curl_cmd[@]}" 2>/dev/null || true) + else + effective_url=$("${curl_cmd[@]}" 2>/dev/null || true) + fi + + # Strip stray carriage returns so string comparisons behave under set -u + effective_url="${effective_url//$'\r'/}" + + if [[ -z "$effective_url" ]]; then + return 1 + fi + + local tag="" + if [[ "$effective_url" =~ /tag/([^/?#]+) ]]; then + tag="${BASH_REMATCH[1]}" + elif [[ "$effective_url" =~ /download/([^/?#]+)/ ]]; then + tag="${BASH_REMATCH[1]}" + fi + + if [[ -z "$tag" ]]; then + return 1 + fi + + printf '%s\n' "$tag" + return 0 +} + detect_lxc_ctid() { local ctid="" @@ -1655,10 +1690,10 @@ download_pulse() { # Fallback: Try direct GitHub redirect if API fails if [[ -z "$LATEST_RELEASE" ]]; then print_info "GitHub API unavailable, trying alternative method..." - if command -v timeout >/dev/null 2>&1; then - LATEST_RELEASE=$(timeout 10 curl -sI --connect-timeout 5 --max-time 10 https://github.com/$GITHUB_REPO/releases/latest 2>/dev/null | grep -i '^location:.*\/tag\/' | sed -E 's|.*tag/([^[:space:]]+).*|\1|' | tr -d '\r' || true) - else - LATEST_RELEASE=$(curl -sI --connect-timeout 5 --max-time 10 https://github.com/$GITHUB_REPO/releases/latest 2>/dev/null | grep -i '^location:.*\/tag\/' | sed -E 's|.*tag/([^[:space:]]+).*|\1|' | tr -d '\r' || true) + local redirect_version="" + redirect_version=$(get_latest_release_from_redirect 2>/dev/null || true) + if [[ -n "$redirect_version" ]]; then + LATEST_RELEASE="$redirect_version" fi fi @@ -2797,10 +2832,10 @@ main() { # If rate limited or failed, try direct GitHub latest URL if [[ -z "$STABLE_VERSION" ]] || [[ "$STABLE_VERSION" == *"rate limit"* ]]; then # Use the GitHub latest release redirect to get version - if command -v timeout >/dev/null 2>&1; then - STABLE_VERSION=$(timeout 10 curl -sI --connect-timeout 5 --max-time 10 https://github.com/$GITHUB_REPO/releases/latest 2>/dev/null | grep -i '^location:' | sed -E 's|.*tag/([^[:space:]]+).*|\1|' | tr -d '\r' || true) - else - STABLE_VERSION=$(curl -sI --connect-timeout 5 --max-time 10 https://github.com/$GITHUB_REPO/releases/latest 2>/dev/null | grep -i '^location:' | sed -E 's|.*tag/([^[:space:]]+).*|\1|' | tr -d '\r' || true) + local redirect_version="" + redirect_version=$(get_latest_release_from_redirect 2>/dev/null || true) + if [[ -n "$redirect_version" ]]; then + STABLE_VERSION="$redirect_version" fi fi diff --git a/scripts/validate-published-release.sh b/scripts/validate-published-release.sh new file mode 100755 index 0000000..06cbdcb --- /dev/null +++ b/scripts/validate-published-release.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +# Remote release validator. +# Downloads the published (or draft) assets straight from GitHub Releases, +# recalculates their SHA256 sums, and ensures checksums.txt and the *.sha256 +# helper files match what is actually live. This prevents broken updates when +# artifacts are re-uploaded without regenerating checksums (see issue #698). + +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 [owner/repo]" >&2 + echo "Example: $0 v4.28.0 rcourtman/Pulse" >&2 + exit 1 +fi + +TAG="$1" +REPO="${2:-rcourtman/Pulse}" +BASE_URL="https://github.com/${REPO}/releases/download/${TAG}" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +curl_args=(curl -fsSL --connect-timeout 10 --max-time 600 --retry 3 --retry-delay 2 --retry-all-errors) + +CHECKSUMS_PATH="${TMP_DIR}/checksums.txt" +echo "Downloading ${BASE_URL}/checksums.txt" +if ! "${curl_args[@]}" "${BASE_URL}/checksums.txt" >"$CHECKSUMS_PATH"; then + echo "Failed to download checksums.txt for ${TAG}" >&2 + exit 1 +fi + +status=0 + +while read -r checksum filename _; do + [[ -z "${checksum:-}" ]] && continue + [[ "$checksum" =~ ^# ]] && continue + if [[ -z "${filename:-}" ]]; then + echo "Malformed checksums line (missing filename): $checksum" >&2 + status=1 + continue + fi + + artifact_url="${BASE_URL}/${filename}" + echo "Verifying ${filename}..." + + if ! actual_checksum=$("${curl_args[@]}" "$artifact_url" | sha256sum | awk '{print $1}'); then + echo "Failed to download ${filename}" >&2 + status=$((status + 1)) + continue + fi + + if [[ "$actual_checksum" != "$checksum" ]]; then + echo "Checksum mismatch for ${filename}: expected ${checksum}, got ${actual_checksum}" >&2 + status=$((status + 1)) + fi + + sha_url="${artifact_url}.sha256" + if ! sha_content=$("${curl_args[@]}" "$sha_url" | tr -d '\r' | sed 's/[[:space:]]*$//'); then + echo "Failed to download ${filename}.sha256" >&2 + status=$((status + 1)) + continue + fi + + expected_line="${checksum} ${filename}" + if [[ "$sha_content" != "$expected_line" ]]; then + echo "${filename}.sha256 content mismatch (expected '${expected_line}', got '${sha_content}')" >&2 + status=$((status + 1)) + fi +done < "$CHECKSUMS_PATH" + +if [[ "$status" -ne 0 ]]; then + echo "Published release validation failed for ${TAG} (${status} error(s))." >&2 + exit 1 +fi + +echo "Published release assets for ${TAG} match checksums.txt and *.sha256 files."