[Fix] nvm_download_artifact: only put an archive in the cache once its checksum has passed
`nvm install --offline` installs whatever archive sits at the cache path without checking it again, which is only safe if everything nvm writes there has been verified. When a download failed its checksum, `nvm_download_artifact` removed only the empty `files` directory and left the rejected archive in `$NVM_DIR/.cache`, so a later `nvm install --offline` would install the very tarball the online install had just refused. An interrupted download was also left at the final cache name. Download to a temporary `.unverified` name instead, run the 404 and checksum checks on that file, and move it to the cache path only once the checksum has passed. Every failure path, including a checksum mismatch or a missing expected checksum, now removes the temporary file and the version's cache directory, so `--offline` no longer finds the version at all. The README's offline section now says that only verified downloads are cached and that `--offline` trusts the cache as-is. Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-7NCT51FE.
This commit is contained in:
3 files changed
+98
-6
No files matched your search
@@ -540,6 +540,8 @@ nvm install --offline 14.7.0
|
||||
|
||||
This resolves versions using only locally installed versions and cached downloads. It will not attempt to download anything. This is useful in air-gapped environments, on planes, or when you want to avoid network latency.
|
||||
|
||||
A download is only added to the cache (`$NVM_DIR/.cache`) once its checksum has been verified (unless no checksum tool is available, in which case `nvm` warns that it is continuing without verification). `--offline` does not verify cached archives again, so anything placed in the cache by other means is trusted as-is.
|
||||
|
||||
You can combine `--offline` with `--lts` to install the latest cached LTS version (as long as LTS aliases have been populated by a prior `nvm ls-remote --lts`):
|
||||
|
||||
```sh
|
||||
|
||||
@@ -3868,25 +3868,36 @@ nvm_download_artifact() {
|
||||
nvm_err "Removing the broken local cache..."
|
||||
command rm -rf "${TARBALL}"
|
||||
fi
|
||||
# `--offline` uses the cached archive without a checksum, so only a verified archive may ever sit at ${TARBALL}
|
||||
local UNVERIFIED_TARBALL
|
||||
UNVERIFIED_TARBALL="${TARBALL}.unverified"
|
||||
command rm -f "${UNVERIFIED_TARBALL}"
|
||||
|
||||
nvm_err "Downloading $(nvm_sanitize_url "${TARBALL_URL}")..."
|
||||
# stdout is reserved for the tarball path, so nothing the downloader prints may reach it
|
||||
nvm_download -L -C - "${PROGRESS_BAR}" "${TARBALL_URL}" -o "${TARBALL}" >&2 || {
|
||||
command rm -rf "${TARBALL}" "${tmpdir}"
|
||||
nvm_download -L -C - "${PROGRESS_BAR}" "${TARBALL_URL}" -o "${UNVERIFIED_TARBALL}" >&2 || {
|
||||
command rm -rf "${UNVERIFIED_TARBALL}" "${tmpdir}"
|
||||
nvm_err "download from $(nvm_sanitize_url "${TARBALL_URL}") failed"
|
||||
return 4
|
||||
}
|
||||
|
||||
if nvm_grep '404 Not Found' "${TARBALL}" >/dev/null; then
|
||||
command rm -rf "${TARBALL}" "${tmpdir}"
|
||||
if nvm_grep '404 Not Found' "${UNVERIFIED_TARBALL}" >/dev/null; then
|
||||
command rm -rf "${UNVERIFIED_TARBALL}" "${tmpdir}"
|
||||
nvm_err "HTTP 404 at URL $(nvm_sanitize_url "${TARBALL_URL}")"
|
||||
return 5
|
||||
fi
|
||||
|
||||
nvm_compare_checksum "${TARBALL}" "${CHECKSUM}" || {
|
||||
command rm -rf "${tmpdir}/files"
|
||||
nvm_compare_checksum "${UNVERIFIED_TARBALL}" "${CHECKSUM}" || {
|
||||
command rm -rf "${UNVERIFIED_TARBALL}" "${tmpdir}"
|
||||
return 6
|
||||
}
|
||||
|
||||
command mv -f "${UNVERIFIED_TARBALL}" "${TARBALL}" || {
|
||||
command rm -rf "${UNVERIFIED_TARBALL}" "${tmpdir}"
|
||||
nvm_err "moving the downloaded archive into the cache at $(nvm_sanitize_path "${TARBALL}") failed"
|
||||
return 7
|
||||
}
|
||||
|
||||
nvm_echo "${TARBALL}"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/bin/sh
|
||||
|
||||
WORK="${TMPDIR:-/tmp}/nvm_download_artifact_cache.$$"
|
||||
|
||||
cleanup () {
|
||||
command rm -rf "${WORK}"
|
||||
unset -f cleanup die download_offline nvm_get_mirror nvm_binary_available nvm_get_download_slug nvm_get_artifact_compression nvm_cache_dir nvm_get_checksum nvm_version_greater_than_or_equal_to nvm_download nvm_compare_checksum
|
||||
}
|
||||
die () { echo "$@" ; cleanup ; exit 1; }
|
||||
|
||||
: nvm.sh
|
||||
\. ../../../nvm.sh
|
||||
|
||||
\. ../../common.sh
|
||||
|
||||
TEST_SLUG='node-v20.0.0-linux-x64'
|
||||
TEST_CACHE_DIR="${WORK}/.cache/bin/${TEST_SLUG}"
|
||||
TEST_TARBALL="${TEST_CACHE_DIR}/${TEST_SLUG}.tar.gz"
|
||||
|
||||
nvm_get_mirror() { nvm_echo 'http://example.com'; }
|
||||
nvm_binary_available() { return 0; }
|
||||
nvm_get_download_slug() { nvm_echo "${TEST_SLUG}"; }
|
||||
nvm_get_artifact_compression() { nvm_echo 'tar.gz'; }
|
||||
nvm_cache_dir() { nvm_echo "${WORK}/.cache"; }
|
||||
nvm_get_checksum() { nvm_echo 'fake_checksum'; }
|
||||
nvm_version_greater_than_or_equal_to() { return 0; }
|
||||
|
||||
# appends "downloaded" to the -o target, and records whether that target is the final cache name
|
||||
nvm_download() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = '-o' ]; then
|
||||
if [ "$2" = "${TEST_TARBALL}" ]; then
|
||||
command touch "${WORK}/downloaded-to-final-name"
|
||||
fi
|
||||
nvm_echo 'downloaded' >> "$2"
|
||||
return 0
|
||||
fi
|
||||
shift
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
download_offline() {
|
||||
NVM_OFFLINE=1
|
||||
nvm_download_artifact node binary std v20.0.0
|
||||
}
|
||||
|
||||
command mkdir -p "${WORK}"
|
||||
|
||||
# a download with no expected checksum to compare against must not be left in the cache
|
||||
# (this uses the real `nvm_compare_checksum`, so it runs before that is mocked)
|
||||
nvm_get_checksum() { nvm_echo ''; }
|
||||
try nvm_download_artifact node binary std v20.0.0
|
||||
[ "${CAPTURED_EXIT_CODE}" = '6' ] || die "empty checksum: expected exit code 6, got ${CAPTURED_EXIT_CODE}"
|
||||
[ ! -e "${TEST_CACHE_DIR}" ] || die "empty checksum: expected ${TEST_CACHE_DIR} to be removed"
|
||||
try download_offline
|
||||
[ "${CAPTURED_EXIT_CODE}" = '4' ] || die "empty checksum: expected --offline to find no cached archive (exit code 4), got ${CAPTURED_EXIT_CODE}"
|
||||
nvm_get_checksum() { nvm_echo 'fake_checksum'; }
|
||||
|
||||
# a download that fails its checksum must not be left in the cache either
|
||||
nvm_compare_checksum() { return 1; }
|
||||
try nvm_download_artifact node binary std v20.0.0
|
||||
[ "${CAPTURED_EXIT_CODE}" = '6' ] || die "checksum mismatch: expected exit code 6, got ${CAPTURED_EXIT_CODE}"
|
||||
[ ! -e "${TEST_CACHE_DIR}" ] || die "checksum mismatch: expected ${TEST_CACHE_DIR} to be removed"
|
||||
try download_offline
|
||||
[ "${CAPTURED_EXIT_CODE}" = '4' ] || die "checksum mismatch: expected --offline to find no cached archive (exit code 4), got ${CAPTURED_EXIT_CODE}"
|
||||
|
||||
# a successful download is moved into the cache only once it is verified, replacing any leftover partial download
|
||||
nvm_compare_checksum() { return 0; }
|
||||
command mkdir -p "${TEST_CACHE_DIR}"
|
||||
nvm_echo 'partial' > "${TEST_TARBALL}.unverified"
|
||||
try nvm_download_artifact node binary std v20.0.0
|
||||
[ "${CAPTURED_EXIT_CODE}" = '0' ] || die "verified download: expected exit code 0, got ${CAPTURED_EXIT_CODE}"
|
||||
[ "${CAPTURED_STDOUT}" = "${TEST_TARBALL}" ] || die "verified download: expected >${TEST_TARBALL}<, got >${CAPTURED_STDOUT}<"
|
||||
[ "$(command cat "${TEST_TARBALL}")" = 'downloaded' ] || die "verified download: expected the cached archive to hold only the new download, got >$(command cat "${TEST_TARBALL}")<"
|
||||
[ ! -e "${TEST_TARBALL}.unverified" ] || die 'verified download: expected no unverified download to remain in the cache'
|
||||
[ ! -e "${WORK}/downloaded-to-final-name" ] || die 'an unverified download was written to the final cache name, where an interrupted download would be left for --offline'
|
||||
|
||||
cleanup
|
||||
Reference in new issue
Block a user