What a curl | sh installer for a Go binary should refuse
· 10 min read
On this page (9)
Sooner or later someone will put curl -fsSL https://example.com/install.sh | sh in a README, yours or theirs. That line downloads code from the network and runs it as the user, so the script deserves more care than almost anything else in the repository. Most install scripts do the happy path well and the failure paths not at all.
This post is the list of cases an installer for a single Go binary should stop on, the shell that does it, and a way to test every refusal without touching the real internet.
What a careful installer does
Who needs this
A CLI tool with a GitHub or GitLab release page. Your users are developers who will paste the one-liner into a terminal on a laptop, a CI runner or a fresh server. You want the first five seconds of their experience to be one command and a working binary. You also do not want to be the project whose installer installed a tampered archive.
An internal tool distributed to a team. The install script lives in the repository, and new hires run it on day one. A typo in the version flag that quietly installs the latest release, or a half-failed download that leaves an empty file in ~/.local/bin, costs someone an afternoon. Failing loudly and leaving the existing install alone matters more here than anywhere.
A server agent installed by provisioning. A cloud-init script or an Ansible task runs the installer on machines nobody is watching. There is no human to notice a mismatch warning, so the script must exit non-zero and install nothing, and your automation must see that exit code.
A project that wants Homebrew and Scoop users and curl users to get the same bytes. All three routes should verify against the same checksums file. The installer is the one you control completely, so it is where the rule gets enforced.
What the one-liner already gets you, and what it does not
curl -fsSL has four useful flags. -f makes HTTP errors (404, 500) fail instead of saving the error page. -s is quiet and -S shows errors anyway. -L follows redirects, which GitHub release URLs need. Piping into sh means that if the connection drops mid-script, the shell may execute a truncated file up to the cut. Writing the whole script as a function and calling it on the last line avoids that:
main() {
# everything the installer does
:
}
main "$@"
With this shape, nothing runs until the entire file has arrived, because the final line is what starts it. It is a small habit worth keeping.
None of this verifies the archive the script downloads. That is the script's job, and it is where the refusals come in.
The refusal list
An installer should stop, install nothing and leave any existing binary untouched when any of the following is true. We test every one of them.
The archive does not match its checksum
This is the reason checksums are published at all. The script downloads the archive and the checksums file, computes the SHA-256 of the archive and compares it with the published line. A mismatch is a hard stop with both hashes in the message, so the user can see what happened.
The checksums file has no entry for the archive
Easy to get wrong. A lookup that finds nothing and then compares the archive hash with an empty string should fail, but a script written without set -u and a careful lookup can "verify" against nothing and carry on. The script must treat "no entry" as an error.
The checksums file names the archive twice
Ambiguity is a reason to stop. Which line is the real one? An attacker who can append a line to the file can also make a first-match lookup pick theirs. Require exactly one match.
Here is the lookup our installer uses, in POSIX sh and awk:
WANT="$(awk -v f="$ARCHIVE" '$2==f || $2=="*" f {print $1; n++} END{exit (n==1) ? 0 : 1}' "$TMP/$SUMS")" ||
die "$SUMS has no single entry for $ARCHIVE; refusing to install"
printf '%s' "$WANT" | grep -Eq '^[0-9a-fA-F]{64}$' || die "malformed checksum entry for $ARCHIVE"
The awk program counts matches and exits non-zero unless there is exactly one. It also accepts the *name form that sha256sum writes for binary mode. The grep afterwards checks that what we extracted is a 64-character hex string, so a truncated or garbled line is rejected instead of compared.
The checksums file or the release is missing
A 404 should be an error with a clear message, not a shell that continues. With set -eu at the top and every download wrapped as fetch ... || die "could not download ...", the script ends at the first failed transfer. Leaving the check implicit in curl -f is enough, as long as the failing command is not on the left of a pipe or inside an if that swallows the exit code.
The URL is plain http
Verification is only meaningful if the checksums file and the archive reach you honestly. Over plain http, anyone on the network path can swap both files at the same time, and the hash will match. Restrict curl to https for the first request and for redirects:
fetch() { curl -fsSL --proto '=https' --proto-redir '=https' --retry 2 --connect-timeout 15 -o "$2" "$1"; }
--proto =https allows only https for the initial URL, and --proto-redir =https does the same for redirects, so a redirect to an http host is refused. Give testers an explicit opt-in, such as an INSTALL_ALLOW_HTTP=1 environment variable that widens the allowed protocols, because you will want to test against a local server. Make the default strict.
The version string is odd
An empty --version "" silently installing the latest release was a real bug in an early draft of ours. A person who asks for a specific version should not get something else. Treat an empty value as an error, strip one leading v, and validate the rest against a semantic version pattern before it goes anywhere near a URL:
VERSION="${VERSION#v}"
printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' ||
die "not a valid version: '$VERSION'"
This also stops a value like 1.2.3/../../evil from being pasted into a download URL.
The platform is not one you built for
If you ship linux and darwin on amd64 and arm64, an installer on armv7, i686, FreeBSD or a Windows-flavoured shell should say so and stop before downloading anything. Map uname -s and uname -m explicitly, and make the default case an error:
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) die "unsupported architecture: $(uname -m) (released: amd64, arm64)" ;;
esac
Do not guess "closest match". Running an amd64 binary under emulation by accident is a support ticket.
The archive does not contain the binary, or it is not a regular file
After verification, extract only the one file you expect, and check it is a regular file, not a symlink pointing somewhere else. The checksum covers the archive, so this is a defence in depth rather than the main control, but it costs one line.
Installing without breaking what is there
Verification protects the user from a bad download. The install step protects them from a half-finished one. Download and extract into a temporary directory created with mktemp -d, remove it in an EXIT trap, and place the binary with a copy to a temporary name in the target directory followed by mv:
cp "$TMP/x/$BIN" "$PREFIX/bin/.$BIN.new.$$"
chmod 0755 "$PREFIX/bin/.$BIN.new.$$"
mv -f "$PREFIX/bin/.$BIN.new.$$" "$PREFIX/bin/$BIN"
A rename within one filesystem is atomic, so a user running mycli in another terminal sees either the old binary or the new one, never a partial file. It also means the second half of "leave an existing install alone" is easy to test: tamper with an archive and check that the old binary is byte for byte what it was.
Default to a user-writable prefix such as ~/.local/bin, and let people choose --prefix /usr/local deliberately. A script that runs sudo on its own is one more thing for a reader to audit and trust.
Testing the refusals
You do not need the real GitHub for any of this. The installer takes a base URL from an environment variable, so a tiny local server with files you control is enough. Use python3 -m http.server over a directory laid out like a release page (/download/v1.2.3/<files>), run the script with the opt-in for plain http, and break one thing at a time.
# build a fake release in ./rel/download/v0.0.1/ using your snapshot archives
( cd rel && python3 -m http.server 8099 --bind 127.0.0.1 ) &
export INSTALL_BASE_URL=http://127.0.0.1:8099 INSTALL_ALLOW_HTTP=1
sh install.sh --version 0.0.1 --prefix "$(mktemp -d)" # should install
# tamper: flip a byte of the archive, keep the checksums file
printf 'x' >> rel/download/v0.0.1/mycli_0.0.1_linux_amd64.tar.gz
sh install.sh --version 0.0.1 --prefix "$(mktemp -d)" # must exit 1
# no opt-in: plain http must be refused
unset INSTALL_ALLOW_HTTP
sh install.sh --version 0.0.1 --prefix "$(mktemp -d)" # must exit 1
Testing refusals without the real internet
Check the exit code each time (echo $?) and check that the prefix directory has no binary. For the platform cases, put a fake uname first on PATH:
mkdir -p fake && printf '#!/bin/sh\necho armv7l\n' > fake/uname && chmod +x fake/uname
PATH="$PWD/fake:$PATH" sh install.sh --version 0.0.1 # must refuse before any download
Do the same for an empty version (--version ""), a version like 1.2 or latest, a checksums file with the archive line deleted, one with the line duplicated, and a missing checksums file. Our release kit's test harness runs 183 checks in a Linux container, including every case on this list, and the installed file is compared byte for byte with the one inside the archive.
Run shellcheck install.sh as well. It catches unquoted variables and non-POSIX constructs that fail only on someone else's dash.
Where checksums stop protecting you
A checksum catches corruption and the replacement of one file. It does not catch the replacement of the archive and the checksums file together, because both come from the same place. If an attacker can edit your release page, a matching pair is trivial to produce.
Checksum alone vs checksum plus signature
A signature on the checksums file closes that gap, provided the user already has your public key from somewhere other than the release page. Our script supports an optional GPG check: when a public key file is configured, it also downloads <checksums>.sig, imports the key into a throwaway GNUPGHOME, and refuses a wrong key, a modified checksums file, a missing signature and a missing key.
It is worth saying plainly what a signature changes. Without a configured public key, a modified checksums file installs happily, which our harness demonstrates on purpose. Only the signature stops it. The longer discussion, with cosign and minisign as alternatives and a table of what each protects against, is in verifying downloads with checksums and signatures.
Rule of thumb: when any check fails, exit non-zero, print why on stderr, and leave the existing install exactly as it was.
Mistakes we see
Verifying against a file fetched from the same URL over http. Everything matches, nothing is proven.
Running sha256sum -c on the whole checksums file inside an installer. The file lists every platform's archive and you downloaded one, so you either get failures for the others or reach for --ignore-missing, which can pass with zero files checked. Select the one line for your archive and compare explicitly.
Accepting any version string. It ends up in a URL and in a file name, so validate it.
curl | sudo sh as the documented default. Offer it as an option, not the headline.
Naming drift. The installer builds the archive name from BIN, the version, the OS and the architecture. If your release configuration changes its name_template, the installer breaks, usually at the worst time. Keep the template in one documented place, as shown in the GoReleaser configuration walkthrough, and run the installer against a real snapshot in CI.
Skipping the read-first path. Say in the README that people can download install.sh, read it, and run it. A script worth piping is a script worth reading.
What each check stops
| Check | Stops |
|---|---|
| SHA-256 compared with the published line | Corrupted or swapped archive |
| Exactly one entry in the checksums file | Ambiguous or appended lines |
| https only, also for redirects | Swapping both files in transit |
| Version pattern | Odd strings becoming part of a URL |
| Explicit platform map | Wrong binary for the machine |
| Temporary directory and atomic rename | Partial installs, broken existing install |
| Signature on checksums | Archive and checksums replaced together |
FAQ
Is curl | sh actually safe?
It is as safe as the script and the host it comes from. The pattern itself is no worse than downloading a binary and running it, but it hides the download from the user's review. A script that verifies checksums, uses https only and fails closed is a reasonable way to deliver a CLI. Offering a manual download and a package manager route alongside it lets cautious users choose.
Should the script verify with sha256sum or compute the hash itself?
Use whatever the machine has, in a fixed order. sha256sum is on Linux, shasum -a 256 on macOS, and openssl dgst -sha256 nearly everywhere. A small function that picks the first one available and fails if none exist keeps the script portable.
Do I need a signature if the checksums are on HTTPS?
HTTPS protects the transfer, not the host. If your release page or account is compromised, the attacker controls both files. A signature made with a key kept off that host is the only thing that still holds. For a small tool with a modest threat model, checksums over HTTPS is a defensible baseline, and the signature is an upgrade for people who ask.
How do I let users pin a version in CI?
Support both --version 1.2.3 and an environment variable, validate the value, and document the exact one-liner with the version baked into the URL of the script itself, for example one pointing at a tag instead of main. That way the script and the binary are fixed together.
What exit codes should the installer use?
Zero only when the binary was installed and verified. One for any refusal. Keep the message on stderr so that provisioning tools capture it, and print nothing that looks like success before the final step.
The installer shown here, with its tests, is one part of the Go CLI Release Kit, alongside the GoReleaser configuration, the reproducibility check and the CI templates. It is at /products/go-release-kit/, and the snippets above are enough to build your own if you prefer.