[Fix] nvm_alias, nvm_version_path: reject .. path components

`nvm_alias` concatenated the requested name onto `$NVM_DIR/alias` and read whatever that resolved to,
so a name with a `..` component escaped the alias dir
- under the default layout, `../../.npmrc` reaches `$HOME/.npmrc`.
`nvm_print_alias_file` echoes every non-comment line of what it opens,
and `nvm use` reports the first one in its "is not yet installed" error,
so an untrusted `.nvmrc` could disclose any file the invoking user can read.

The write side already rejected `..` in an alias name (9275c5ba);
apply the same check on the read side, and to `nvm_version_path`,
which composes a version into a path with no check of its own.

Slashes stay legal, since `lts/iron` and the `lts/*` alias file depend on them.
This commit is contained in:
Jordan Harband
2026-09-21 10:01:34 +08:00
parent 60a8da7a8e
commit 072622c748
2 changed files with 82 additions and 0 deletions
+15
View File
@@ -800,6 +800,12 @@ nvm_alias_path() {
nvm_version_path() {
local VERSION
VERSION="${1-}"
case "/${VERSION}/" in
*/../*)
nvm_err "invalid version: ${VERSION}"
return 3
;;
esac
if [ -z "${VERSION}" ]; then
nvm_err 'version is required'
return 3
@@ -1498,6 +1504,15 @@ nvm_alias() {
return 2
fi
# slashes are legal (eg `lts/iron`), but a `..` component would read outside
# the alias dir; `nvm_make_alias` rejects the same shape on the write side
case "/${ALIAS}/" in
*/../*)
nvm_err "invalid alias name: ${ALIAS}"
return 3
;;
esac
local NVM_ALIAS_PATH
NVM_ALIAS_PATH="$(nvm_alias_path)/${ALIAS}"
if [ ! -f "${NVM_ALIAS_PATH}" ]; then
+67
View File
@@ -0,0 +1,67 @@
#!/bin/sh
ORIG_NVM_DIR="${NVM_DIR}"
TMP_DIR=''
cleanup () {
case "${TMP_DIR}" in
/*) rm -rf "${TMP_DIR}" ;;
esac
NVM_DIR="${ORIG_NVM_DIR}"
export NVM_DIR
}
die () { echo "$@" ; cleanup ; exit 1; }
: nvm.sh
\. ../../../nvm.sh
TMP_DIR="$(mktemp -d)"
NVM_DIR="${TMP_DIR}/nvm"
export NVM_DIR
mkdir -p "${NVM_DIR}/alias/lts" || die 'could not create the scratch alias dir'
# a file outside $NVM_DIR that alias resolution must never reach
printf '%s\n' 'SECRET_OUTSIDE_NVM_DIR' > "${TMP_DIR}/outside"
# legitimate aliases, to prove the guard does not reject the names nvm uses
printf '%s\n' 'v1.2.3' > "${NVM_DIR}/alias/default"
printf '%s\n' 'v4.5.6' > "${NVM_DIR}/alias/lts/iron"
for TRAVERSAL in '../../outside' 'lts/../../../outside' '..' 'lts/..' 'a/../../outside'; do
OUTPUT="$(nvm_alias "${TRAVERSAL}" 2>/dev/null)"
EXIT_CODE=$?
[ "_${EXIT_CODE}" = '_3' ] || die "'nvm_alias ${TRAVERSAL}' exited with ${EXIT_CODE}, expected 3"
[ -z "${OUTPUT}" ] || die "'nvm_alias ${TRAVERSAL}' produced output: ${OUTPUT}"
# capture stderr through a file, and match rather than compare: ksh 93u+ has
# no `local`, so it interleaves its own noise with anything nvm.sh writes
nvm_alias "${TRAVERSAL}" >/dev/null 2>"${TMP_DIR}/stderr"
grep -qF "invalid alias name: ${TRAVERSAL}" "${TMP_DIR}/stderr" \
|| die "'nvm_alias ${TRAVERSAL}' produced stderr: $(cat "${TMP_DIR}/stderr")"
done
# the disclosure path the advisory reports: resolution must not echo the file
for TRAVERSAL in '../../outside' 'lts/../../../outside'; do
OUTPUT="$(nvm_resolve_alias "${TRAVERSAL}" 2>&1)"
case "${OUTPUT}" in
*SECRET_OUTSIDE_NVM_DIR*) die "'nvm_resolve_alias ${TRAVERSAL}' leaked a file outside \$NVM_DIR" ;;
esac
done
OUTPUT="$(nvm_alias default)"
[ "_${OUTPUT}" = '_v1.2.3' ] || die "'nvm_alias default' produced \"${OUTPUT}\", expected v1.2.3"
OUTPUT="$(nvm_alias lts/iron)"
[ "_${OUTPUT}" = '_v4.5.6' ] || die "'nvm_alias lts/iron' produced \"${OUTPUT}\", expected v4.5.6"
for BAD_VERSION in '../../outside' 'v1.2.3/../../..' '..'; do
OUTPUT="$(nvm_version_path "${BAD_VERSION}" 2>/dev/null)"
EXIT_CODE=$?
[ "_${EXIT_CODE}" = '_3' ] || die "'nvm_version_path ${BAD_VERSION}' exited with ${EXIT_CODE}, expected 3"
[ -z "${OUTPUT}" ] || die "'nvm_version_path ${BAD_VERSION}' produced output: ${OUTPUT}"
done
OUTPUT="$(nvm_version_path v18.0.0)"
[ "_${OUTPUT}" = "_${NVM_DIR}/versions/node/v18.0.0" ] || die "'nvm_version_path v18.0.0' produced \"${OUTPUT}\""
cleanup