Skip to content

Commit 1e443ae

Browse files
authored
chore(install): verify downloaded tarball integrity + enforce HTTPS (#1221)
* chore(install): verify downloaded tarball integrity + enforce HTTPS Tightens install.sh so the downloaded tarball is checked against the integrity value the npm registry publishes for that specific version. Changes: * Consolidated curl/wget into `fetch_url` and `fetch_url_to_file` helpers that pass `--proto =https --tlsv1.2` (curl) or `--https-only` (wget) so we don't silently follow an http redirect. * After download, compute the SSRI-style hash (e.g. `sha512-<base64>`) of the tarball and compare to the value returned by `GET /<pkg>/<version>`. Mismatch aborts before extraction. * Download the tarball into a `mktemp` file outside the install dir so a failed verification can't leave a partial blob where a later run might trust it; an `EXIT` trap cleans up. Behavior for users on a successful install is unchanged. * fix(install): harden integrity parsing and drop xxd dependency Two follow-up fixes flagged by Cursor bugbot on #1221: 1. `get_published_integrity` pipeline tripped `set -e` under `pipefail` when npm metadata was truncated/malformed. Extract the body first, then parse via a `parse_json_string` helper that tolerates a missing field (returns empty). The caller's "refusing to install without a published checksum" message now fires with context instead of the script exiting silently. 2. `compute_integrity` relied on `xxd -r -p` in the `shasum` fallback branch to convert hex to binary. `xxd` isn't POSIX — it ships with vim-common and is often absent on minimal/Alpine containers. With `set -e` the missing binary killed the script before the friendly else-branch error could print. Replaced the fallback stack with a single openssl-only path. openssl is ubiquitous (macOS, mainstream Linux, default Alpine image, WSL, Git Bash); requiring it removes the portability bug and simplifies the function. If openssl is genuinely missing the script now prints a platform-specific install hint. Verified locally: * happy path (real install) still passes * simulated missing-integrity response prints the helpful message * tampered-download still fails with expected vs got mismatch * fix(install): close wget HTTPS-downgrade gap with --max-redirect=0 Cursor bugbot flagged that wget's `--https-only` only applies to recursive downloads — for the single-file fetches in this script it's effectively a no-op, so a MITM could theoretically redirect us to an http:// mirror. curl's `--proto '=https'` closed this correctly; wget had a gap. Swap `--https-only` for `--max-redirect=0` on both wget calls. npm's registry serves metadata and tarballs directly with no redirect (verified with `curl -sI`), so this is safe for the happy path and hard-blocks any attempt to downgrade to http via redirect. Side benefit: the two metadata fetches (version + integrity) also got this protection. Previously the tarball was covered by the integrity check that follows, but a MITM on the metadata call could have fed us a fake version + matching fake integrity value. Now that vector is closed too.
1 parent 00ef6f4 commit 1e443ae

File tree

1 file changed

+122
-18
lines changed

1 file changed

+122
-18
lines changed

install.sh

Lines changed: 122 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,20 @@ detect_platform() {
115115
echo "${os}-${arch}${libc_suffix}"
116116
}
117117

118-
# Get the latest version from npm registry.
119-
get_latest_version() {
120-
local package_name="$1"
121-
local version
118+
# Fetch a URL to stdout, enforcing HTTPS.
119+
#
120+
# curl enforces HTTPS via `--proto '=https'`. wget's `--https-only` only
121+
# applies to recursive downloads, so for the single-file fetches we do
122+
# here we disable redirect following (`--max-redirect=0`) — npm's
123+
# registry serves responses directly with no redirect, so this is safe
124+
# AND blocks any MITM attempt to redirect us to http://.
125+
fetch_url() {
126+
local url="$1"
122127

123-
# Try using curl with npm registry API.
124128
if command -v curl &> /dev/null; then
125-
version=$(curl -fsSL "https://registry.npmjs.org/${package_name}/latest" | grep -o '"version": *"[^"]*"' | head -1 | sed 's/"version": *"\([^"]*\)"/\1/')
126-
# Fallback to wget.
129+
curl --proto '=https' --tlsv1.2 -fsSL "$url"
127130
elif command -v wget &> /dev/null; then
128-
version=$(wget -qO- "https://registry.npmjs.org/${package_name}/latest" | grep -o '"version": *"[^"]*"' | head -1 | sed 's/"version": *"\([^"]*\)"/\1/')
131+
wget --max-redirect=0 -qO- "$url"
129132
else
130133
error "Neither curl nor wget found on your system"
131134
echo ""
@@ -135,6 +138,44 @@ get_latest_version() {
135138
info " Fedora: sudo dnf install curl"
136139
exit 1
137140
fi
141+
}
142+
143+
# Download a URL to a file, enforcing HTTPS (see `fetch_url` comment).
144+
fetch_url_to_file() {
145+
local url="$1"
146+
local out="$2"
147+
148+
if command -v curl &> /dev/null; then
149+
curl --proto '=https' --tlsv1.2 -fsSL -o "$out" "$url"
150+
elif command -v wget &> /dev/null; then
151+
wget --max-redirect=0 -qO "$out" "$url"
152+
else
153+
error "Neither curl nor wget found on your system"
154+
exit 1
155+
fi
156+
}
157+
158+
# Parse a JSON string field out of a response body. Tolerates a missing
159+
# field by returning empty, rather than dying under `pipefail`.
160+
parse_json_string() {
161+
local body="$1"
162+
local field="$2"
163+
# Pipe through `cat` so a grep non-match (exit 1) doesn't trip pipefail;
164+
# the final `echo` replaces an empty match with empty string.
165+
printf '%s' "$body" \
166+
| grep -o "\"${field}\": *\"[^\"]*\"" \
167+
| head -1 \
168+
| sed "s/\"${field}\": *\"\\([^\"]*\\)\"/\\1/" \
169+
|| true
170+
}
171+
172+
# Get the latest version from npm registry.
173+
get_latest_version() {
174+
local package_name="$1"
175+
local body version
176+
177+
body=$(fetch_url "https://registry.npmjs.org/${package_name}/latest")
178+
version=$(parse_json_string "$body" "version")
138179

139180
if [ -z "$version" ]; then
140181
error "Failed to fetch latest version from npm registry"
@@ -147,6 +188,41 @@ get_latest_version() {
147188
echo "$version"
148189
}
149190

191+
# Get the npm-published integrity string (SSRI format, e.g. "sha512-...") for
192+
# a specific version.
193+
get_published_integrity() {
194+
local package_name="$1"
195+
local version="$2"
196+
local body
197+
198+
body=$(fetch_url "https://registry.npmjs.org/${package_name}/${version}")
199+
parse_json_string "$body" "integrity"
200+
}
201+
202+
# Compute an SSRI-style hash (e.g. "sha512-<base64>") of a file.
203+
# Requires `openssl` — the tool is ubiquitous (macOS, every mainstream
204+
# Linux distro, Alpine's default image, WSL, Git Bash) and gives us a
205+
# one-step hex-less pipeline so we don't depend on `xxd` (not POSIX).
206+
compute_integrity() {
207+
local file="$1"
208+
local algo="$2"
209+
local digest
210+
211+
if ! command -v openssl &> /dev/null; then
212+
error "openssl not found — required to verify the download integrity"
213+
echo ""
214+
info "Install openssl and re-run:"
215+
info " macOS: already installed (or: brew install openssl)"
216+
info " Alpine: apk add openssl"
217+
info " Debian: sudo apt-get install openssl"
218+
info " Fedora: sudo dnf install openssl"
219+
exit 1
220+
fi
221+
222+
digest=$(openssl dgst "-${algo}" -binary "$file" | openssl base64 -A)
223+
echo "${algo}-${digest}"
224+
}
225+
150226
# Calculate SHA256 hash of a string.
151227
calculate_hash() {
152228
local str="$1"
@@ -201,16 +277,43 @@ install_socket_cli() {
201277
# Create installation directory.
202278
mkdir -p "$install_dir"
203279

204-
# Download tarball to temporary location.
205-
local temp_tarball="${install_dir}/socket.tgz"
206-
207-
if command -v curl &> /dev/null; then
208-
curl -fsSL -o "$temp_tarball" "$download_url"
209-
elif command -v wget &> /dev/null; then
210-
wget -qO "$temp_tarball" "$download_url"
280+
# Look up the integrity string the registry published for this exact version.
281+
step "Fetching published integrity..."
282+
local expected_integrity
283+
expected_integrity=$(get_published_integrity "$package_name" "$version")
284+
if [ -z "$expected_integrity" ]; then
285+
error "No integrity found in the npm registry metadata for ${package_name}@${version}"
286+
info "Refusing to install without a published checksum to verify against."
287+
exit 1
211288
fi
212289

213-
success "Package downloaded successfully"
290+
# Algorithm prefix from the SSRI string (e.g. "sha512-..." -> "sha512").
291+
local integrity_algo="${expected_integrity%%-*}"
292+
293+
# Download tarball to a temporary location outside the install dir so a
294+
# failed verify can't leave a partial blob where future runs might trust it.
295+
local temp_tarball
296+
if command -v mktemp &> /dev/null; then
297+
temp_tarball=$(mktemp -t socket-cli.XXXXXX.tgz 2>/dev/null || mktemp "${TMPDIR:-/tmp}/socket-cli.XXXXXX")
298+
else
299+
temp_tarball="${TMPDIR:-/tmp}/socket-cli.$$.tgz"
300+
fi
301+
trap 'rm -f "$temp_tarball"' EXIT
302+
303+
fetch_url_to_file "$download_url" "$temp_tarball"
304+
305+
# Verify integrity against the value npm published for this version.
306+
step "Verifying integrity..."
307+
local actual_integrity
308+
actual_integrity=$(compute_integrity "$temp_tarball" "$integrity_algo")
309+
if [ "$actual_integrity" != "$expected_integrity" ]; then
310+
error "Integrity check failed for ${package_name}@${version}"
311+
info " expected: ${expected_integrity}"
312+
info " got: ${actual_integrity}"
313+
info "Not installing. Please retry; if this persists, open an issue."
314+
exit 1
315+
fi
316+
success "Integrity verified (${integrity_algo})"
214317

215318
# Extract tarball.
216319
step "Capturing lightning in a bottle ⚡"
@@ -250,8 +353,9 @@ install_socket_cli() {
250353
fi
251354
fi
252355

253-
# Clean up tarball.
254-
rm "$temp_tarball"
356+
# Clean up tarball (EXIT trap also handles this in error paths).
357+
rm -f "$temp_tarball"
358+
trap - EXIT
255359

256360
success "Binary ready at ${BOLD}$binary_path${NC}"
257361

0 commit comments

Comments
 (0)