From f45a446e3b9b66a5d94972e00a5a39c50ae03cd4 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 4 Sep 2026 09:45:58 -0400 Subject: [PATCH 1/4] fix: Checksum match must allow the ./ prefix real releases carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer refuses every install against every published release: error: checksums.txt has no entry for abctl_v0.7.0-alpha.3_darwin_arm64.tar.gz — refusing to install it unverified The entry is there. The release workflow generates checksums with `sha256sum ./*.tar.gz`, so every line reads "HASH ./abctl_....tar.gz", and the pattern I introduced in 79f2f575 — "[[:space:]]\*?NAME$" — requires the name immediately after whitespace or a binary-mode asterisk. A "./" in between means nothing matches. That commit fixed a fail-OPEN (an alternation succeeded on one of two archives, so a partial checksums.txt installed the other unverified) and replaced it with a fail-CLOSED that blocks everyone. The fail-closed is the safer direction of the two, but it is still a bug, and it is worse in practice: nobody can install at all. The pattern now accepts the name preceded by start-of-line, whitespace, "*", or "/", which covers "./name", "dist/name", "*name" and a bare "name". Tested against the real published checksums.txt for v0.7.0-alpha.3 plus six constructed cases: no prefix, binary mode, a nested path, partial coverage (the fail-open this guard exists for — still caught), a decoy where the name appears mid-line, and a suffix impostor "xyzabctl_....tar.gz". All seven behave. End to end, the exact failing command now verifies both archives ("./abctl_...: OK", "./authbridge-proxy_...: OK") and installs both binaries. The lesson worth recording: the first version of this guard was never run against a real checksums.txt, only against fixtures I wrote from the same mistaken assumption about the format. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/install.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/authbridge/install.sh b/authbridge/install.sh index d89fcc84..943ee4e7 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -276,7 +276,12 @@ info "Verifying checksums..." # checksums.txt can't make verification fail on a file we never fetched. : > "${tmp}/checksums.filtered" for archive in "${abctl_tgz}" "${proxy_tgz}"; do - grep -E "[[:space:]]\*?${archive}\$" "${tmp}/checksums.txt" >> "${tmp}/checksums.filtered" \ + # The name may be preceded by whitespace, sha256sum's binary-mode "*", or a + # path component: the release workflow runs `sha256sum ./*.tar.gz`, so every + # real line reads "HASH ./abctl_....tar.gz". An earlier version of this + # pattern required the name immediately after whitespace or "*", which matched + # nothing against an actual release and refused every install. + grep -E "(^|[[:space:]*/])${archive}\$" "${tmp}/checksums.txt" >> "${tmp}/checksums.filtered" \ || die "checksums.txt has no entry for ${archive} — refusing to install it unverified" done # Both entries present, and exactly the two we asked for. From c4d5ea45ae6e3c6e688fd454a10206d9e23cfe2d Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 4 Sep 2026 10:09:21 -0400 Subject: [PATCH 2/4] feat: Install from the newest release, not from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented command fetches install.sh from main and runs it. main is whatever landed last, so a `curl | sh` executes unreviewed and unreleased changes on someone's laptop the moment they merge — which is exactly how a broken checksum pattern of mine reached a user and blocked every install. The script now re-runs the copy from the newest release and hands it the same arguments. Releases are tested; main is not. Two escape hatches: --ref=main run this copy, unreleased changes included --ref=vX.Y.Z pin the installer to a release AUTHBRIDGE_REF is the environment equivalent. When the script came from a release tag, the binaries default to that same tag, so the script and the binaries it installs are one tested set rather than two independently-moving things; AUTHBRIDGE_VERSION still overrides. Details that took a test to get right: - --ref is stripped before re-exec. A released script from before --ref existed rejects it as an unknown option, which is exactly what happened on the first run of this. - The argument list is rebuilt by rotating the positional parameters rather than building a string, so an argument containing a space survives. - AUTHBRIDGE_SCRIPT_REF is both the ref name and the recursion guard: the child sees it set and does not bootstrap again. Verified the bootstrap line appears exactly once. - If the resolved ref has no authbridge/install.sh, it warns and continues with the current copy. That is not hypothetical: the newest release today is v0.7.0-alpha.3, which predates the rename from install-demo.sh, so the fallback is the live path until the next release exists. Tested: the default (falls back with a warning today), --ref=main, --ref with a commit SHA that does have the script, AUTHBRIDGE_REF, the recursion guard, argument propagation, version pinning, and --help through the documented pipe. The SHA test is the one worth naming: the parent had the checksum fix and the child did not, and the child failed on the checksum bug — which is direct evidence the re-exec runs the pinned copy's code rather than the parent's. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 4 ++ authbridge/install.sh | 96 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 16e14707..47da8d9f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ decrypted and parsed live. No Kubernetes. macOS or Linux, amd64 or arm64. | sh -s -- --claude-code ``` + The URL is on `main`, but the script immediately re-runs the copy from the + newest **release** — so a `curl | sh` never executes an unreleased change. + Add `--ref=main` to opt into main anyway, or `--ref=vX.Y.Z` to pin. + 2. **Open the viewer** in another terminal: ```sh diff --git a/authbridge/install.sh b/authbridge/install.sh index 943ee4e7..df0c2843 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -36,8 +36,15 @@ # curl, not sh, so the script runs without it. `sh -s -- --flag` has no such # failure mode. The env vars below still work. # +# By default this script re-runs the copy from the newest RELEASE rather than +# executing whatever is currently on main — main is unstable by definition, and a +# `curl | sh` should not be the first thing to run a change nobody has released. +# --ref=main opts back in; --ref=vX.Y.Z pins. +# # Environment: -# AUTHBRIDGE_VERSION=vX.Y.Z install a specific release tag (default: newest) +# AUTHBRIDGE_REF=REF same as --ref +# AUTHBRIDGE_VERSION=vX.Y.Z install binaries from a specific release +# (default: the release this script came from) # AUTHBRIDGE_INSTALL_ONLY=1 same as --install-only # AUTHBRIDGE_SKIP_DOWNLOAD=1 use the already-installed binaries in ~/.local/bin # instead of downloading (re-run setup offline) @@ -77,6 +84,10 @@ Options: --claude-code after starting, offer to configure Claude Code to use it, so it runs as plain `claude` with no environment variables --local the default, spelled out + --ref=REF take THIS SCRIPT from a git ref instead of the newest release + (e.g. --ref=main for unreleased changes, --ref=v0.7.0-alpha.4 + to pin). Binaries come from the same release unless + AUTHBRIDGE_VERSION says otherwise. -h, --help this text Environment: @@ -97,6 +108,7 @@ for arg in "$@"; do case "$arg" in --install-only) MODE=install-only ;; --claude-code) WIRE_CLAUDE_CODE=1 ;; + --ref=*) AUTHBRIDGE_REF="${arg#*=}" ;; # --local is the default; accepted so writing it out explicitly works, and # so it mirrors the proxy flag of the same name. --local) MODE=local ;; @@ -104,7 +116,7 @@ for arg in "$@"; do usage exit 0 ;; - *) die "unknown option: $arg (try --claude-code, --install-only, --local, or no argument)" ;; + *) die "unknown option: $arg (try --claude-code, --install-only, --local, --ref=REF, or no argument)" ;; esac done # Env form kept working; the flag wins if both are given. @@ -115,6 +127,70 @@ fi command -v curl >/dev/null 2>&1 || die "curl is required" command -v tar >/dev/null 2>&1 || die "tar is required" +# newest_release prints the newest release tag, prereleases included. +# `releases/latest` excludes prereleases and this project ships them, so list +# releases (newest first) and take the first tag_name. +newest_release() { + curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" 2>/dev/null \ + | grep -m1 '"tag_name"' | sed -e 's/.*"tag_name": *"//' -e 's/".*//' +} + +# --- run the released copy of this script, not the one from main --- +# +# The documented command fetches this file from main, which is whatever landed +# last: an unreviewed or half-finished change there runs on someone's laptop +# immediately. Releases are tested, so by default this bootstrap re-runs the copy +# from the newest release and hands it the same arguments. +# +# SCRIPT_REF names the ref this copy came from and doubles as the recursion guard: +# the child sees it set and does not bootstrap again. +SCRIPT_REF="${AUTHBRIDGE_SCRIPT_REF:-}" +if [ -z "${SCRIPT_REF}" ]; then + want_ref="${AUTHBRIDGE_REF:-}" + if [ -z "${want_ref}" ]; then + want_ref="$(newest_release)" || true + fi + if [ -z "${want_ref}" ]; then + warn "could not resolve the newest release; continuing with the copy from main" + SCRIPT_REF="main" + elif [ "${want_ref}" = "main" ]; then + # Explicitly asked for main: this copy already is main. + SCRIPT_REF="main" + else + # Rebuild the argument list without --ref: it is meta, consumed here, and a + # released script from before --ref existed rejects it as an unknown option. + # Rotating the positional parameters keeps arguments with spaces intact, + # which building a string would not. + argc=$# + argi=0 + while [ "${argi}" -lt "${argc}" ]; do + a="$1" + shift + argi=$((argi + 1)) + case "$a" in + --ref=*) ;; + *) set -- "$@" "$a" ;; + esac + done + + boot=$(mktemp) + url="https://raw.githubusercontent.com/${REPO}/${want_ref}/authbridge/install.sh" + if curl -fsSL "${url}" -o "${boot}" 2>/dev/null && [ -s "${boot}" ]; then + info "Using the installer from ${want_ref}." + AUTHBRIDGE_SCRIPT_REF="${want_ref}" sh "${boot}" "$@" + status=$? + rm -f "${boot}" + exit "${status}" + fi + rm -f "${boot}" + # A release from before this script existed under that name, or a network + # blip. Falling back is better than refusing to install, but say which + # copy is running so a surprise is attributable. + warn "${want_ref} has no authbridge/install.sh; continuing with the copy from main" + SCRIPT_REF="main" + fi +fi + # Verify the checklist file passed as $1 (run from the directory holding the # files). shasum is preferred: it's always present on macOS and its -c reads the # GNU-style checksums.txt reliably, whereas some non-GNU sha256sum builds reject @@ -241,14 +317,18 @@ if [ "${AUTHBRIDGE_SKIP_DOWNLOAD:-}" = "1" ]; then else # --- resolve the release tag --- -# `releases/latest` excludes prereleases, and the project ships prereleases, so -# list releases (newest first) and take the first tag_name instead. version="${AUTHBRIDGE_VERSION:-}" if [ -z "$version" ]; then - info "Resolving newest release..." - version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" \ - | grep -m1 '"tag_name"' | sed -e 's/.*"tag_name": *"//' -e 's/".*//') - [ -n "$version" ] || die "could not resolve the newest release (set AUTHBRIDGE_VERSION=vX.Y.Z)" + # Default the binaries to the same release this script came from, so the + # script and the binaries it installs are one tested set rather than two + # independently-moving things. + case "${SCRIPT_REF}" in + v*) version="${SCRIPT_REF}" ;; + *) + info "Resolving newest release..." + version=$(newest_release) || die "could not resolve the newest release (set AUTHBRIDGE_VERSION=vX.Y.Z)" + ;; + esac fi info "Release: $version" From b4eb264b85004c1c38b48481f0a2ec7e3b944928 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 4 Sep 2026 10:23:21 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20Address=20review=20=E2=80=94=20unrea?= =?UTF-8?q?chable=20die,=20leaked=20tempfile,=20misattributed=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings, all verified against the code first, all real. **The die after newest_release could never fire.** It is a pipeline ending in sed, which exits 0 on empty input, so `version=$(newest_release) || die` passed an empty tag through on a rate-limited or offline API. The user then got `Release: ` and `download failed: abctl__darwin_arm64.tar.gz` instead of the actionable "set AUTHBRIDGE_VERSION". newest_release now returns 1 on empty, so both call sites work — the other one was already correct, and having the two disagree is what hid this. **set -eu made the cleanup after the re-exec dead code.** A child exiting non-zero aborts the parent immediately, so `status=$?`, `rm -f` and `exit` never ran and the downloaded script leaked on every failed install. Reproduced. Now if/else, which keeps the status and still cleans up. **The fallback warning misattributed network failures.** One branch collapsed 404, transport errors and empty-200, and 2>/dev/null discarded curl's reason — so someone behind a blocked raw.githubusercontent.com was told the release "has no authbridge/install.sh", which is false, and was then dropped onto main: the exact outcome this bootstrap exists to prevent. Now split on the status code. A 404 means that ref genuinely predates the script, so fall back and say so. A transport error means we could not ask, so it dies and names both explicit choices rather than quietly running main. **The archive name went into an ERE unescaped**, so every dot matched any character and abctl_v0X7X0-alpha_3_darwin_arm64Xtar.gz satisfied the pattern. Not exploitable — a decoy either pushes the count off 2 or reaches sha_check looking for a file that was never downloaded, and both die — but this commit's subject is precision in that one pattern. Escaped now; the decoy is rejected and the real name still matches. **The README claimed more than the code does.** "never executes an unreleased change" is untrue today (alpha.3's tree 404s, so the live path warns and runs main) and not absolute afterwards either, since both fallbacks continue with main by design. Now: re-runs the release copy "when one carries it", and "normally does not execute unreleased changes". Also fixed a detail my own test caught: on a transport failure curl prints "000" via -w and exits non-zero, so appending a default produced "HTTP 000000". Regression pass over all seven paths: default 404 fallback, --ref=main, --ref= bootstrapping exactly once, transport error dying without falling back, both checksums verifying against the real ./-prefixed file, no tempfile leak on a failing child, and --help through the documented pipe. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 6 ++--- authbridge/install.sh | 62 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 47da8d9f..5ea440de 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ decrypted and parsed live. No Kubernetes. macOS or Linux, amd64 or arm64. | sh -s -- --claude-code ``` - The URL is on `main`, but the script immediately re-runs the copy from the - newest **release** — so a `curl | sh` never executes an unreleased change. - Add `--ref=main` to opt into main anyway, or `--ref=vX.Y.Z` to pin. + The URL is on `main`, but the script re-runs the copy from the newest + **release** when one carries it, so a `curl | sh` normally does not execute + unreleased changes. Add `--ref=main` to opt into main, or `--ref=vX.Y.Z` to pin. 2. **Open the viewer** in another terminal: diff --git a/authbridge/install.sh b/authbridge/install.sh index df0c2843..4c1af584 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -131,8 +131,24 @@ command -v tar >/dev/null 2>&1 || die "tar is required" # `releases/latest` excludes prereleases and this project ships them, so list # releases (newest first) and take the first tag_name. newest_release() { - curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" 2>/dev/null \ - | grep -m1 '"tag_name"' | sed -e 's/.*"tag_name": *"//' -e 's/".*//' + # Returns non-zero on empty. The pipeline ends in sed, which exits 0 for empty + # input, so `version=$(newest_release) || die` could never fire on a + # rate-limited or offline API — the caller got an empty tag and failed later + # with an unactionable "download failed: abctl__darwin_arm64.tar.gz". + _tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" 2>/dev/null \ + | grep -m1 '"tag_name"' | sed -e 's/.*"tag_name": *"//' -e 's/".*//') + [ -n "${_tag}" ] || return 1 + printf '%s\n' "${_tag}" +} + +# ere_escape quotes the ERE metacharacters in a literal so it matches exactly. +# Archive names contain dots, and an unescaped "." matches any character: the +# pattern for abctl_v0.7.0-alpha.3_..tar.gz also accepted +# abctl_v0X7X0-alpha_3_..Xtar.gz. Nothing exploitable followed — the count check +# or sha_check rejected it — but this script's whole subject is precision here. +ere_escape() { + # shellcheck disable=SC2016 # the sed script is literal on purpose + printf '%s' "$1" | sed 's/[].[^$()*+?{}|\\]/\\&/g' } # --- run the released copy of this script, not the one from main --- @@ -175,19 +191,42 @@ if [ -z "${SCRIPT_REF}" ]; then boot=$(mktemp) url="https://raw.githubusercontent.com/${REPO}/${want_ref}/authbridge/install.sh" - if curl -fsSL "${url}" -o "${boot}" 2>/dev/null && [ -s "${boot}" ]; then + # Capture the status code rather than collapsing every failure into one + # branch. A 404 means that ref genuinely predates this script — fall back. + # A transport error means we could not ask, and silently dropping to main + # there would break the exact guarantee this bootstrap exists to give. + # On a transport failure curl still prints "000" via -w AND exits non-zero, + # so appending our own default produced "HTTP 000000". Overwrite instead. + http=$(curl -sSL -o "${boot}" -w '%{http_code}' "${url}" 2>/dev/null) || http="000" + [ -n "${http}" ] || http="000" + if [ "${http}" = "200" ] && [ -s "${boot}" ]; then info "Using the installer from ${want_ref}." - AUTHBRIDGE_SCRIPT_REF="${want_ref}" sh "${boot}" "$@" - status=$? + # set -e would abort the parent on a non-zero child before any of the + # lines below ran, leaking the downloaded script on every failed + # install. The if/else keeps the status and still cleans up. + if AUTHBRIDGE_SCRIPT_REF="${want_ref}" sh "${boot}" "$@"; then + status=0 + else + status=$? + fi rm -f "${boot}" exit "${status}" fi rm -f "${boot}" - # A release from before this script existed under that name, or a network - # blip. Falling back is better than refusing to install, but say which - # copy is running so a surprise is attributable. - warn "${want_ref} has no authbridge/install.sh; continuing with the copy from main" - SCRIPT_REF="main" + if [ "${http}" = "404" ]; then + # A release from before this script existed under that name. Falling + # back beats refusing to install, but name the copy that is running so + # a surprise is attributable. + warn "${want_ref} has no authbridge/install.sh (HTTP 404); continuing with the copy from main" + SCRIPT_REF="main" + else + # Blocked, offline, rate-limited, proxied, 5xx. We cannot tell whether a + # released installer exists, so do not quietly run main instead. + die "could not fetch the installer for ${want_ref} (HTTP ${http}) from ${url}. + Check the network, or choose explicitly: + --ref=main run the copy from main (unreleased changes) + --ref=vX.Y.Z use a specific release" + fi fi fi @@ -361,7 +400,8 @@ for archive in "${abctl_tgz}" "${proxy_tgz}"; do # real line reads "HASH ./abctl_....tar.gz". An earlier version of this # pattern required the name immediately after whitespace or "*", which matched # nothing against an actual release and refused every install. - grep -E "(^|[[:space:]*/])${archive}\$" "${tmp}/checksums.txt" >> "${tmp}/checksums.filtered" \ + archive_re=$(ere_escape "${archive}") + grep -E "(^|[[:space:]*/])${archive_re}\$" "${tmp}/checksums.txt" >> "${tmp}/checksums.filtered" \ || die "checksums.txt has no entry for ${archive} — refusing to install it unverified" done # Both entries present, and exactly the two we asked for. From a249aeaf0b22bbd66cbcd8ab0d389c6004dee87b Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 4 Sep 2026 10:25:50 -0400 Subject: [PATCH 4/4] fix: Checksum entries may not name a path (CWE-345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sha_check runs from ${tmp}, and the character class I used to accept the "./" prefix also accepted "/". So a crafted checksums.txt entry could name a file outside the download directory and still match: HASH ../abctl_v0.7.0-alpha.3_darwin_arm64.tar.gz matched HASH /etc/abctl_v0.7.0-alpha.3_darwin_arm64.tar.gz matched HASH ../../tmp/abctl_v...tar.gz matched Verification would then run against that file while the archive we actually downloaded gets extracted — passing the check for something other than the thing installed. Exploiting it needs a file to already exist at the named path with a known hash, so it is difficult rather than easy, but a checksum guard is the wrong place to leave that. The pattern is now anchored to the whole line and to the exact shape our own workflow emits — `cd dist && sha256sum ./*.tar.gz` gives "HASH ./name" — with a bare name and binary-mode "*" also accepted: ^[0-9a-fA-F]+[[:space:]]+\*?(\./)?NAME$ Nested paths like "dist/name" are rejected too. I had allowed them earlier on the guess that some sha256sum invocation might produce them; ours does not, and guessing at extra formats is what opened this. Ten cases tested: the three legitimate shapes match; traversal, absolute, nested, a wildcard-dot decoy, a suffix impostor, and a non-hex first field are all rejected. The real published checksums.txt for v0.7.0-alpha.3 still verifies both archives and installs. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/install.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/authbridge/install.sh b/authbridge/install.sh index 4c1af584..6806b3d6 100755 --- a/authbridge/install.sh +++ b/authbridge/install.sh @@ -400,9 +400,19 @@ for archive in "${abctl_tgz}" "${proxy_tgz}"; do # real line reads "HASH ./abctl_....tar.gz". An earlier version of this # pattern required the name immediately after whitespace or "*", which matched # nothing against an actual release and refused every install. + # Anchored to the whole line and to the exact shape our own workflow emits: + # "HASH ./name" (from `cd dist && sha256sum ./*.tar.gz`), with a bare name and + # binary-mode "*" also accepted. + # + # Deliberately NOT any path. sha_check runs from ${tmp}, so a permissive class + # let a crafted entry like "HASH ../name" or "HASH /etc/name" match and be + # verified against a file outside the download directory — passing verification + # for something other than the archive we then extract. Only ./ and a bare name + # are ours, so nothing else is accepted. archive_re=$(ere_escape "${archive}") - grep -E "(^|[[:space:]*/])${archive_re}\$" "${tmp}/checksums.txt" >> "${tmp}/checksums.filtered" \ - || die "checksums.txt has no entry for ${archive} — refusing to install it unverified" + grep -E "^[0-9a-fA-F]+[[:space:]]+\*?(\./)?${archive_re}\$" "${tmp}/checksums.txt" \ + >> "${tmp}/checksums.filtered" \ + || die "checksums.txt has no usable entry for ${archive} — refusing to install it unverified" done # Both entries present, and exactly the two we asked for. lines=$(wc -l < "${tmp}/checksums.filtered" | tr -d '[:space:]')