1
0
mirror of https://github.com/nvm-sh/nvm.git synced 2026-10-26 00:14:32 +08:00

[Fix] nvm_install_binary, nvm_install_source: only use a tarball that nvm_download_artifact put in the cache

`nvm_install_binary` and `nvm_install_source` took the tarball path from the last line of `nvm_download_artifact`'s output via `| tail -1`.
That pipeline's exit status is `tail`'s,
so a failed download went unnoticed,
and the downloader's own stdout was not separated from the path:
anything it printed
(eg, from a curl `write-out` setting)
could be the last line.
If that line happened to name an existing file relative to the current directory,
it was extracted and installed as if it were the downloaded tarball,
instead of the install failing.

`nvm_download_artifact` now sends the downloader's output to stderr,
so its stdout is only ever the tarball path.
Both install functions capture its output directly and honor its exit status,
and accept the result only if it is an existing file inside nvm's cache directory,
via a new `nvm_is_cached_artifact` helper

Discovered by Anthropic's OSS Scanner, as ANT-2026-DESJ2ESZ; the scanner reported it as a vulnerability, but it is not one.
This commit is contained in:
Jordan Harband
2026-10-18 23:12:42 -07:00
parent c8a2a81f92
commit 37c578d973
4 changed files with 212 additions and 6 deletions

21
nvm.sh
View File

@@ -2974,8 +2974,8 @@ nvm_install_binary() {
PROGRESS_BAR="--progress-bar"
fi
nvm_echo "Downloading and installing ${NODE_OR_IOJS-} ${VERSION}..."
TARBALL="$(PROGRESS_BAR="${PROGRESS_BAR}" nvm_download_artifact "${FLAVOR}" binary "${TYPE-}" "${VERSION}" | command tail -1)"
if [ -f "${TARBALL}" ]; then
TARBALL="$(PROGRESS_BAR="${PROGRESS_BAR}" nvm_download_artifact "${FLAVOR}" binary "${TYPE-}" "${VERSION}")" || TARBALL=''
if nvm_is_cached_artifact "${TARBALL}"; then
TMPDIR="$(dirname "${TARBALL}")/files"
fi
@@ -3169,7 +3169,8 @@ nvm_download_artifact() {
command rm -rf "${TARBALL}"
fi
nvm_err "Downloading ${TARBALL_URL}..."
nvm_download -L -C - "${PROGRESS_BAR}" "${TARBALL_URL}" -o "${TARBALL}" || {
# 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_err "download from ${TARBALL_URL} failed"
return 4
@@ -3189,6 +3190,14 @@ nvm_download_artifact() {
nvm_echo "${TARBALL}"
}
# a tarball path from nvm_download_artifact is only usable if it is an existing file in nvm's cache
nvm_is_cached_artifact() {
case "${1-}" in
"$(nvm_cache_dir)"/*) [ -f "${1}" ] ;;
*) return 1 ;;
esac
}
# args: nvm_os, version, tarball, tmpdir
nvm_extract_tarball() {
if [ "$#" -ne 4 ]; then
@@ -3376,8 +3385,8 @@ nvm_install_source() {
nvm_is_zsh && setopt local_options shwordsplit
TARBALL="$(PROGRESS_BAR="${PROGRESS_BAR}" nvm_download_artifact "${FLAVOR}" source "${TYPE}" "${VERSION}" | command tail -1)" && \
[ -f "${TARBALL}" ] && \
TARBALL="$(PROGRESS_BAR="${PROGRESS_BAR}" nvm_download_artifact "${FLAVOR}" source "${TYPE}" "${VERSION}")" && \
nvm_is_cached_artifact "${TARBALL}" && \
TMPDIR="$(dirname "${TARBALL}")/files" && \
if ! (
# shellcheck disable=SC2086
@@ -5443,7 +5452,7 @@ nvm() {
nvm_ls_remote nvm_ls_remote_iojs nvm_ls_remote_index_tab nvm_get_remote_aliases \
nvm_ls nvm_remote_version nvm_remote_versions \
nvm_install_binary nvm_install_source nvm_clang_version \
nvm_get_mirror nvm_get_download_slug nvm_download_artifact \
nvm_get_mirror nvm_get_download_slug nvm_download_artifact nvm_is_cached_artifact \
nvm_install_npm_if_needed nvm_use_if_needed nvm_check_file_permissions \
nvm_print_versions nvm_compute_checksum \
nvm_get_checksum_binary \

View File

@@ -0,0 +1,54 @@
#!/bin/sh
WORK="${PWD}/nvm-download-artifact-stdout-work.$$"
cleanup() {
command rm -rf "${WORK}"
NVM_DIR="${OLD_NVM_DIR}"
unset -f cleanup die nvm_download nvm_get_checksum nvm_compare_checksum
}
die () { echo "$@" ; cleanup ; exit 1; }
: nvm.sh
\. ../../../nvm.sh
\. ../../common.sh
OLD_NVM_DIR="${NVM_DIR}"
NVM_DIR="${WORK}/nvm"
command mkdir -p "${NVM_DIR}"
nvm_get_checksum() { echo checksum; }
nvm_compare_checksum() { return 0; }
# a downloader that prints to stdout, as curl does with a `write-out` setting
nvm_download() {
while [ $# -gt 0 ]; do
if [ "$1" = '-o' ]; then
echo tarball > "$2"
fi
shift
done
echo 200
}
try nvm_download_artifact node binary std 8.0.0
[ "${CAPTURED_EXIT_CODE}" = 0 ] || die "nvm_download_artifact failed; got exit code ${CAPTURED_EXIT_CODE}"
case "${CAPTURED_STDOUT}" in
"$(nvm_cache_dir)"/bin/*) ;;
*) die "nvm_download_artifact printed more than the cached tarball path; got '${CAPTURED_STDOUT}'" ;;
esac
[ -f "${CAPTURED_STDOUT}" ] || die "nvm_download_artifact printed a path that does not exist: '${CAPTURED_STDOUT}'"
command rm -rf "$(nvm_cache_dir)"
nvm_download() {
echo 404
return 22
}
try nvm_download_artifact node binary std 8.0.0
[ "${CAPTURED_EXIT_CODE}" = 4 ] || die "a failed download should exit with 4; got ${CAPTURED_EXIT_CODE}"
[ -z "${CAPTURED_STDOUT}" ] || die "a failed download should print nothing to stdout; got '${CAPTURED_STDOUT}'"
cleanup

View File

@@ -0,0 +1,59 @@
#!/bin/sh
WORK="${PWD}/nvm-tarball-from-cache-work.$$"
EXTRACT_LOG="${WORK}/extract.log"
cleanup() {
command rm -rf "${WORK}"
NVM_DIR="${OLD_NVM_DIR}"
unset -f cleanup die nvm_download_artifact nvm_extract_tarball run_in_project
}
die () { echo "$@" ; cleanup ; exit 1; }
: nvm.sh
\. ../../../nvm.sh
OLD_NVM_DIR="${NVM_DIR}"
NVM_DIR="${WORK}/nvm"
CACHED_TARBALL="$(nvm_cache_dir)/bin/node-v8.0.0-linux-x64/node-v8.0.0-linux-x64.tar.xz"
command mkdir -p "${WORK}/project" "$(dirname "${CACHED_TARBALL}")"
echo tarball > "${CACHED_TARBALL}"
# a file in the current directory named like a line a downloader might print
echo not-a-cached-tarball > "${WORK}/project/404"
nvm_extract_tarball() {
echo "$3" >> "${EXTRACT_LOG}"
return 1
}
run_in_project() {
command rm -f "${EXTRACT_LOG}"
(
cd "${WORK}/project" || exit 1
"$@" < /dev/null > /dev/null 2>&1
)
[ -f "${EXTRACT_LOG}" ] && command cat "${EXTRACT_LOG}"
:
}
# a failed download's last stdout line must not be taken as the tarball
nvm_download_artifact() { echo 404; return 4; }
[ -z "$(run_in_project nvm_install_binary node std 8.0.0 1)" ] || die 'nvm_install_binary extracted the output of a failed download'
[ -z "$(run_in_project nvm_install_source node std 8.0.0)" ] || die 'nvm_install_source extracted the output of a failed download'
# nor may any path outside nvm's cache
nvm_download_artifact() { echo 404; }
[ -z "$(run_in_project nvm_install_binary node std 8.0.0 1)" ] || die 'nvm_install_binary extracted a tarball outside the cache'
[ -z "$(run_in_project nvm_install_source node std 8.0.0)" ] || die 'nvm_install_source extracted a tarball outside the cache'
# a tarball in nvm's cache is still extracted
nvm_download_artifact() { nvm_echo "${CACHED_TARBALL}"; }
[ "$(run_in_project nvm_install_binary node std 8.0.0 1)" = "${CACHED_TARBALL}" ] || die 'nvm_install_binary did not extract the cached tarball'
echo tarball > "${CACHED_TARBALL}"
[ "$(run_in_project nvm_install_source node std 8.0.0)" = "${CACHED_TARBALL}" ] || die 'nvm_install_source did not extract the cached tarball'
cleanup

View File

@@ -0,0 +1,84 @@
#!/bin/sh
WORK="${PWD}/nvm-is-cached-artifact-work.$$"
cleanup() {
command rm -rf "${WORK}"
NVM_DIR="${OLD_NVM_DIR}"
unset -f cleanup die check
}
die () { echo "$@" ; cleanup ; exit 1; }
\. ../../../nvm.sh
OLD_NVM_DIR="${NVM_DIR}"
check() {
EXPECTED="${1}"
shift
ACTUAL=0
nvm_is_cached_artifact "$@" || ACTUAL=$?
[ "${ACTUAL}" = "${EXPECTED}" ] || die "nvm_is_cached_artifact '$*' (NVM_DIR '${NVM_DIR}'): expected exit code ${EXPECTED}, got ${ACTUAL}"
}
NVM_DIR="${WORK}/nvm"
CACHE="$(nvm_cache_dir)"
[ "${CACHE}" = "${NVM_DIR}/.cache" ] || die "expected cache dir '${NVM_DIR}/.cache', got '${CACHE}'"
command mkdir -p "${CACHE}/bin/node-v8.0.0-linux-x64" "${CACHE}/src/node-v8.0.0/files" "${WORK}/project" "${WORK}/nvm/.cache-other" "${WORK}/outside"
echo tarball > "${CACHE}/bin/node-v8.0.0-linux-x64/node-v8.0.0-linux-x64.tar.xz"
echo tarball > "${CACHE}/top-level.tar.gz"
echo not-a-cached-tarball > "${WORK}/project/404"
echo not-a-cached-tarball > "${WORK}/outside/node-v8.0.0.tar.xz"
echo not-a-cached-tarball > "${WORK}/nvm/.cache-other/node-v8.0.0.tar.xz"
echo not-a-cached-tarball > "${WORK}/nvm/.cachefile"
# existing files inside the cache are accepted, at any depth
check 0 "${CACHE}/bin/node-v8.0.0-linux-x64/node-v8.0.0-linux-x64.tar.xz"
check 0 "${CACHE}/top-level.tar.gz"
# no argument, or an empty one, is rejected
check 1
check 1 ''
# paths in the cache that are not existing regular files are rejected
check 1 "${CACHE}"
check 1 "${CACHE}/"
check 1 "${CACHE}/bin"
check 1 "${CACHE}/src/node-v8.0.0/files"
check 1 "${CACHE}/bin/node-v8.0.0-linux-x64/missing.tar.xz"
# existing files outside the cache are rejected
check 1 "${WORK}/outside/node-v8.0.0.tar.xz"
check 1 "${NVM_DIR}/.cachefile"
check 1 "${NVM_DIR}/.cache-other/node-v8.0.0.tar.xz"
# a downloader's stray output that names a file relative to the current directory is rejected
(
cd "${WORK}/project" || exit 2
[ -f 404 ] || exit 2
nvm_is_cached_artifact 404 && exit 3
exit 0
)
RESULT=$?
[ "${RESULT}" = 0 ] || die "nvm_is_cached_artifact '404' in a directory containing '404': expected rejection, got subshell exit code ${RESULT}"
# a relative path that happens to reach a cached file is still rejected, since it is not under the cache dir
(
cd "${CACHE}" || exit 2
[ -f top-level.tar.gz ] || exit 2
nvm_is_cached_artifact top-level.tar.gz && exit 3
exit 0
)
RESULT=$?
[ "${RESULT}" = 0 ] || die "nvm_is_cached_artifact 'top-level.tar.gz' from inside the cache dir: expected rejection, got subshell exit code ${RESULT}"
# glob characters in NVM_DIR are matched literally, not as a pattern
NVM_DIR="${WORK}/n*"
command mkdir -p "$(nvm_cache_dir)"
echo tarball > "$(nvm_cache_dir)/glob.tar.xz"
echo not-a-cached-tarball > "${WORK}/nvm/.cache/glob.tar.xz"
check 0 "${WORK}/n*/.cache/glob.tar.xz"
check 1 "${WORK}/nvm/.cache/glob.tar.xz"
cleanup