[Fix] nvm_download: pass $NVM_AUTH_HEADER to curl and GNU Wget in a config file, not as an argument

The header was passed as a `--header` argument, so it was readable by anyone who could see the downloader's command line.

curl now reads it as `header = "…"` from a config on stdin (`--config -`); the sanitized value can contain neither `"` nor `\` nor a line break, so it cannot escape the quoted string or add another option.
GNU Wget only reads a config file that is a regular file, so a subshell writes the header to a `mktemp` file in `$TMPDIR` (mode 0600), after a copy of the user's `$WGETRC` or `~/.wgetrc`, which that file replaces, and points `WGETRC` at it;
traps on EXIT, HUP, INT, and TERM remove it, and `WGETRC` never reaches the user's shell.
BusyBox wget reads no config file and has no option for one, so it still gets the header as an argument; the README recommends curl on shared machines.

Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-540RL2U6.
This commit is contained in:
Jordan Harband committed 2026-10-17 22:28:01 -07:00
1 parent bce663e387
commit 903e349369
5 files changed
+319 -12

No files matched your search

+2
View File
@@ -701,6 +701,8 @@ The header is only sent with requests for URLs under a mirror, never to any othe
`curl` 7.58.0 and later, which nvm prefers, drops the header when the mirror redirects to another host. When only `wget` is available, nvm makes GNU Wget follow a redirect only if it stays on the same origin (scheme, host, and port), and fails on one that does not, since `wget` would send the header there too; use `curl` with a mirror that hands downloads off to another host. Other `wget`s, like BusyBox's, cannot be kept from following redirects, and send the header wherever the mirror redirects.
nvm gives the header to `curl` and to GNU Wget in a config file (for GNU Wget, a private temporary file that also holds a copy of your `~/.wgetrc`), not as a command-line argument, which other users of the same machine could read. Other `wget`s, like BusyBox's, read no config file, so on a machine shared with other users, install `curl` before using `$NVM_AUTH_HEADER`.
`$NVM_AUTH_HEADER` is the recommended way to authenticate to a mirror. Credentials embedded in the mirror URL itself (`https://user:password@host/...`) also work, and nvm prints such URLs with the credentials masked as `***@`.
### Platforms without official binaries
+40 -9
View File
@@ -219,11 +219,18 @@ nvm_download() {
return 0
fi
# every local user can read a process's arguments (`ps`, /proc/<pid>/cmdline),
# so the credential goes to the downloader in a config file, not an argument
if [ -n "${sanitized_header}" ]; then
if [ "${NVM_DOWNLOADER}" = 'wget' ] && nvm_wget_is_gnu; then
nvm_wget_same_origin "${sanitized_header}" "$@"
if [ "${NVM_DOWNLOADER}" = 'curl' ]; then
nvm_echo "header = \"Authorization: ${sanitized_header}\"" | command curl "$@" --config -
return $?
fi
if nvm_wget_is_gnu; then
nvm_wget_with_auth_header "${sanitized_header}" "$@"
return $?
fi
# other wgets, like BusyBox's, read no config file
set -- "$@" --header "Authorization: ${sanitized_header}"
fi
@@ -243,15 +250,39 @@ nvm_url_origin() {
}
# args: sanitized Authorization header value, wget arguments
# GNU Wget only reads a config file that is a regular file, so the header goes
# in a private temporary one; it replaces ~/.wgetrc, so that is copied into it.
nvm_wget_with_auth_header() {
(
NVM_WGETRC="$(command mktemp "${TMPDIR:-/tmp}/nvm-wgetrc.XXXXXX")" || {
nvm_err 'unable to create a temporary file for $NVM_AUTH_HEADER'
exit 1
}
trap 'command rm -f "${NVM_WGETRC}"' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
{
if [ -r "${WGETRC:-${HOME-}/.wgetrc}" ]; then
command cat "${WGETRC:-${HOME-}/.wgetrc}"
nvm_echo
fi
nvm_echo "header = Authorization: ${1-}"
} > "${NVM_WGETRC}" || exit 1
shift
WGETRC="${NVM_WGETRC}"
export WGETRC
nvm_wget_same_origin "$@"
)
}
# args: wget arguments; the header comes from $WGETRC
# `curl -L` (7.58.0 and later) does not send a custom Authorization header to a
# redirect target on another host, but wget sends `--header` along to every
# redirect target on another host, but wget sends its headers along to every
# one. So GNU Wget is stopped at every redirect, which is then followed only if
# it stays on the same origin (scheme, host, and port). Other wgets, like
# BusyBox's, cannot be stopped from following redirects.
nvm_wget_same_origin() {
local NVM_WGET_HEADER
NVM_WGET_HEADER="Authorization: ${1-}"
shift
local NVM_WGET_URL
NVM_WGET_URL=''
local NVM_WGET_ARG
@@ -268,13 +299,13 @@ nvm_wget_same_origin() {
local NVM_WGET_REDIRECTS
NVM_WGET_REDIRECTS=0
while :; do
command wget --max-redirect=0 "$@" --header "${NVM_WGET_HEADER}"
command wget --max-redirect=0 "$@"
NVM_WGET_EXIT_CODE=$?
# wget exits with 8 for an HTTP error status, including a redirect it did not follow
if [ "${NVM_WGET_EXIT_CODE}" != 8 ] || [ -z "${NVM_WGET_URL}" ]; then
return "${NVM_WGET_EXIT_CODE}"
fi
NVM_WGET_LOCATION="$(command wget --max-redirect=0 -q -S -O /dev/null "${NVM_WGET_URL}" --header "${NVM_WGET_HEADER}" 2>&1 \
NVM_WGET_LOCATION="$(command wget --max-redirect=0 -q -S -O /dev/null "${NVM_WGET_URL}" 2>&1 \
| command awk 'tolower($1) == "location:" { print $2; exit }' | command tr -d '\r')"
case "${NVM_WGET_LOCATION}" in
'') return "${NVM_WGET_EXIT_CODE}" ;;
@@ -6654,7 +6685,7 @@ nvm() {
nvm_node_version_has_solaris_binary nvm_iojs_version_has_solaris_binary \
nvm_curl_libz_support nvm_command_info nvm_is_zsh nvm_stdout_is_terminal \
nvm_npmrc_bad_news_bears nvm_sanitize_auth_header nvm_auth_header_applies_to \
nvm_wget_is_gnu nvm_url_origin nvm_wget_same_origin \
nvm_wget_is_gnu nvm_url_origin nvm_wget_same_origin nvm_wget_with_auth_header \
nvm_get_colors nvm_set_colors nvm_print_color_code nvm_wrap_with_color_code \
nvm_print_color_legend \
nvm_echo_with_colors nvm_err_with_colors \
@@ -0,0 +1,87 @@
#!/bin/sh
WORK="$PWD/nvm_download-auth-argv-work.$$"
TEST_BIN="$WORK/bin"
ARGV_LOG="$WORK/argv.log"
STDIN_LOG="$WORK/stdin.log"
WGETRC_LOG="$WORK/wgetrc.log"
WGETRC_PATH_LOG="$WORK/wgetrc-path.log"
WGETRC_MODE_LOG="$WORK/wgetrc-mode.log"
OLDHOME="$HOME"
cleanup() {
unset -f die cleanup nvm_has_executable
unset NVM_AUTH_HEADER WGET_VERSION
rm -rf "$WORK"
export PATH="$OLDPATH"
HOME="$OLDHOME"
export HOME
}
die () { echo "$@" ; cleanup ; exit 1; }
\. ../../../nvm.sh
\. ../../common.sh
OLDPATH="$PATH"
# fake curl: record every argument, and what it is given on stdin
make_fake_curl "$TEST_BIN" 'for a in "$@"; do printf "%s\n" "$a"; done > "$ARGV_LOG"
cat > "$STDIN_LOG"'
VERSION_MESSAGE="curl 7.99.0 (fake)"
# fake GNU Wget: record every argument, and the config file $WGETRC names
{
echo '#!/bin/sh'
echo 'if [ "$1" = --version ]; then echo "GNU Wget 1.21.2 built on linux-gnu."; exit 0; fi'
echo 'for a in "$@"; do printf "%s\n" "$a"; done > "$ARGV_LOG"'
echo 'printf "%s\n" "${WGETRC-}" > "$WGETRC_PATH_LOG"'
echo 'cat "${WGETRC:-/dev/null}" > "$WGETRC_LOG"'
echo 'ls -l "${WGETRC:-/dev/null}" | cut -c1-10 > "$WGETRC_MODE_LOG"'
echo 'exit 0'
} > "$TEST_BIN/wget"
chmod +x "$TEST_BIN/wget"
export ARGV_LOG STDIN_LOG WGETRC_LOG WGETRC_PATH_LOG WGETRC_MODE_LOG
export PATH="$TEST_BIN:$OLDPATH"
NVM_AUTH_HEADER='Bearer test-token'
export NVM_AUTH_HEADER
# given curl
# when nvm_download sends a credential
nvm_download -L -s 'https://nodejs.org/dist/index.tab' -o - || die 'nvm_download (curl) returned nonzero'
# then it is not an argument, which every local user can read in the process list
! grep -q 'test-token' "$ARGV_LOG" || die "curl received the credential as an argument; got: $(cat "$ARGV_LOG")"
# but a header in the config curl reads from stdin
grep -Fxqe '--config' "$ARGV_LOG" || die "curl was not told to read a config; got: $(cat "$ARGV_LOG")"
[ "$(cat "$STDIN_LOG")" = 'header = "Authorization: Bearer test-token"' ] || die "curl did not get the header as config; got: $(cat "$STDIN_LOG")"
# given a credential with a line break in it
NVM_AUTH_HEADER="$(printf 'Bearer test-token\noutput = /tmp/nvm-pwned')"
nvm_download -L -s 'https://nodejs.org/dist/index.tab' -o - || die 'nvm_download (curl) returned nonzero for a line break'
# then it cannot add another option to the config
[ "$(wc -l < "$STDIN_LOG")" -eq 1 ] || die "a line break in the credential reached curl's config; got: $(cat "$STDIN_LOG")"
NVM_AUTH_HEADER='Bearer test-token'
# given only GNU Wget, and the user's own ~/.wgetrc
nvm_has_executable() { [ "$1" != curl ] && command -v "$1" >/dev/null 2>&1; }
HOME="$WORK/home"
export HOME
mkdir -p "$HOME"
printf 'tries = 7' > "$HOME/.wgetrc"
# when nvm_download sends a credential
nvm_download -L -s 'https://nodejs.org/dist/index.tab' -o - || die 'nvm_download (wget) returned nonzero'
# then it is not an argument
! grep -q 'test-token' "$ARGV_LOG" || die "wget received the credential as an argument; got: $(cat "$ARGV_LOG")"
# but a header in a config file only the user can read, after the user's own settings
[ "$(cat "$WGETRC_MODE_LOG")" = '-rw-------' ] || die "the wget config file is readable by others: $(cat "$WGETRC_MODE_LOG")"
[ "$(sed -n 1p "$WGETRC_LOG")" = 'tries = 7' ] || die "the user's ~/.wgetrc was not kept; got: $(cat "$WGETRC_LOG")"
[ "$(sed -n '$p' "$WGETRC_LOG")" = 'header = Authorization: Bearer test-token' ] || die "wget did not get the header as config; got: $(cat "$WGETRC_LOG")"
# which is gone once wget is done
[ -n "$(cat "$WGETRC_PATH_LOG")" ] || die 'wget was not given a config file'
[ ! -e "$(cat "$WGETRC_PATH_LOG")" ] || die "the wget config file was left behind: $(cat "$WGETRC_PATH_LOG")"
# and $WGETRC is not left set in the shell
[ -z "${WGETRC-}" ] || die "WGETRC was left set to ${WGETRC}"
cleanup
+3 -3
View File
@@ -61,11 +61,11 @@ chmod +x "$TEST_BIN/wget"
export CALL_LOG
export PATH="$TEST_BIN:$OLDPATH"
# the Authorization header goes along with every request
HEADER='<--header><Authorization: Bearer test-token>'
# the Authorization header comes from $WGETRC, never from an argument
HEADER=''
run() {
: > "$CALL_LOG"
try_err nvm_wget_same_origin 'Bearer test-token' "$@"
try_err nvm_wget_same_origin "$@"
}
# args: description, expected calls (one per argument)
+187
View File
@@ -0,0 +1,187 @@
#!/bin/sh
WORK="$PWD/nvm_wget_with_auth_header-work.$$"
OLDHOME="$HOME"
OLDTMPDIR="${TMPDIR-}"
cleanup() {
unset -f nvm_wget_same_origin
unset WGETRC MOCK_EXIT MOCK_KILL
rm -rf "$WORK"
HOME="$OLDHOME"
export HOME
TMPDIR="$OLDTMPDIR"
export TMPDIR
}
die () { echo "$@" ; cleanup ; exit 1; }
\. ../../../nvm.sh
mkdir -p "$WORK/home" "$WORK/tmp" || die 'unable to create the work directory'
HOME="$WORK/home"
export HOME
TMPDIR="$WORK/tmp"
export TMPDIR
unset WGETRC
# records what wget would be run with; the real one runs `command wget`
nvm_wget_same_origin() {
: > "$WORK/args"
for arg in "$@"; do
printf '%s\n' "$arg" >> "$WORK/args"
done
printf '%s\n' "${WGETRC-}" > "$WORK/wgetrc-path"
if [ -n "${WGETRC-}" ] && [ -f "${WGETRC}" ]; then
cat "${WGETRC}" > "$WORK/wgetrc"
ls -l "${WGETRC}" | cut -c1-10 > "$WORK/wgetrc-mode"
else
: > "$WORK/wgetrc"
: > "$WORK/wgetrc-mode"
fi
if [ -n "${MOCK_KILL-}" ]; then
# the subshell running this function is the parent of this `sh`
sh -c "kill -${MOCK_KILL} \$PPID"
return 0
fi
return "${MOCK_EXIT:-0}"
}
reset_logs() {
rm -f "$WORK/args" "$WORK/wgetrc-path" "$WORK/wgetrc" "$WORK/wgetrc-mode"
}
assert_cleaned_up() {
[ -n "$(cat "$WORK/wgetrc-path")" ] || die "$1: expected WGETRC to be set for wget, got it empty"
[ ! -e "$(cat "$WORK/wgetrc-path")" ] || die "$1: expected the config file to be removed, got $(cat "$WORK/wgetrc-path") left behind"
[ -z "$(ls -A "$WORK/tmp")" ] || die "$1: expected \$TMPDIR to be empty, got: $(ls -A "$WORK/tmp")"
}
# given no wgetrc of the user's
reset_logs
nvm_wget_with_auth_header 'Bearer test-token' -q -O - 'https://example.com/dist/index.tab'
EXIT_CODE=$?
[ "$EXIT_CODE" = 0 ] || die "no wgetrc: expected exit code 0, got $EXIT_CODE"
# then the header is the only line of the config
[ "$(cat "$WORK/wgetrc")" = 'header = Authorization: Bearer test-token' ] || die "no wgetrc: expected only the header in the config, got >$(cat "$WORK/wgetrc")<"
# and the remaining arguments, without the header, go to nvm_wget_same_origin
EXPECTED_ARGS='-q
-O
-
https://example.com/dist/index.tab'
[ "$(cat "$WORK/args")" = "$EXPECTED_ARGS" ] || die "no wgetrc: expected args >$EXPECTED_ARGS<, got >$(cat "$WORK/args")<"
! grep -q 'test-token' "$WORK/args" || die "no wgetrc: expected the credential not to be an argument, got >$(cat "$WORK/args")<"
# in a file in $TMPDIR that only the user can read
case "$(cat "$WORK/wgetrc-path")" in
"$WORK/tmp/nvm-wgetrc."*) ;;
*) die "no wgetrc: expected the config file in \$TMPDIR, got $(cat "$WORK/wgetrc-path")" ;;
esac
[ "$(cat "$WORK/wgetrc-mode")" = '-rw-------' ] || die "no wgetrc: expected mode -rw-------, got $(cat "$WORK/wgetrc-mode")"
assert_cleaned_up 'no wgetrc'
# and WGETRC does not reach the caller
[ -z "${WGETRC+set}" ] || die "no wgetrc: expected WGETRC to stay unset, got >${WGETRC}<"
# given wget's exit code
reset_logs
MOCK_EXIT=8
nvm_wget_with_auth_header 'Bearer test-token' 'https://example.com/dist/index.tab'
EXIT_CODE=$?
unset MOCK_EXIT
# then it is returned
[ "$EXIT_CODE" = 8 ] || die "wget failure: expected exit code 8, got $EXIT_CODE"
assert_cleaned_up 'wget failure'
# given the user's ~/.wgetrc, with no trailing newline
reset_logs
printf 'tries = 7' > "$HOME/.wgetrc"
nvm_wget_with_auth_header 'Bearer test-token' 'https://example.com/dist/index.tab' || die 'with ~/.wgetrc: expected exit code 0, got nonzero'
# then its settings are kept, and the header is on a line of its own after them
EXPECTED_RC='tries = 7
header = Authorization: Bearer test-token'
[ "$(cat "$WORK/wgetrc")" = "$EXPECTED_RC" ] || die "with ~/.wgetrc: expected >$EXPECTED_RC<, got >$(cat "$WORK/wgetrc")<"
assert_cleaned_up 'with ~/.wgetrc'
[ "$(cat "$HOME/.wgetrc")" = 'tries = 7' ] || die "with ~/.wgetrc: expected ~/.wgetrc unchanged, got >$(cat "$HOME/.wgetrc")<"
# given $WGETRC naming the user's config, alongside a ~/.wgetrc
reset_logs
printf 'timeout = 3\n' > "$WORK/custom-wgetrc"
WGETRC="$WORK/custom-wgetrc"
export WGETRC
nvm_wget_with_auth_header 'Bearer test-token' 'https://example.com/dist/index.tab' || die 'with $WGETRC: expected exit code 0, got nonzero'
# then $WGETRC is the one copied, not ~/.wgetrc
EXPECTED_RC='timeout = 3
header = Authorization: Bearer test-token'
[ "$(cat "$WORK/wgetrc")" = "$EXPECTED_RC" ] || die "with \$WGETRC: expected >$EXPECTED_RC<, got >$(cat "$WORK/wgetrc")<"
assert_cleaned_up 'with $WGETRC'
# and the caller's WGETRC is unchanged
[ "${WGETRC-}" = "$WORK/custom-wgetrc" ] || die "with \$WGETRC: expected WGETRC to stay $WORK/custom-wgetrc, got >${WGETRC-}<"
[ "$(cat "$WORK/custom-wgetrc")" = 'timeout = 3' ] || die "with \$WGETRC: expected it unchanged, got >$(cat "$WORK/custom-wgetrc")<"
# given $WGETRC naming a file that does not exist
reset_logs
WGETRC="$WORK/missing-wgetrc"
nvm_wget_with_auth_header 'Bearer test-token' 'https://example.com/dist/index.tab' || die 'with a missing $WGETRC: expected exit code 0, got nonzero'
# then only the header is in the config; ~/.wgetrc is not used in its place
[ "$(cat "$WORK/wgetrc")" = 'header = Authorization: Bearer test-token' ] || die "with a missing \$WGETRC: expected only the header, got >$(cat "$WORK/wgetrc")<"
assert_cleaned_up 'with a missing $WGETRC'
unset WGETRC
rm -f "$HOME/.wgetrc"
# given a header value with shell metacharacters
reset_logs
nvm_wget_with_auth_header 'Bearer $HOME `id` $(id) *' 'https://example.com/dist/index.tab' || die 'metacharacters: expected exit code 0, got nonzero'
# then it is written verbatim
[ "$(cat "$WORK/wgetrc")" = 'header = Authorization: Bearer $HOME `id` $(id) *' ] || die "metacharacters: expected the header verbatim, got >$(cat "$WORK/wgetrc")<"
assert_cleaned_up 'metacharacters'
# given that the temporary file cannot be created
reset_logs
TMPDIR="$WORK/nonexistent"
ERR="$(nvm_wget_with_auth_header 'Bearer test-token' 'https://example.com/dist/index.tab' 2>&1 >/dev/null)"
EXIT_CODE=$?
TMPDIR="$WORK/tmp"
# then it fails, says why, and never runs wget
[ "$EXIT_CODE" = 1 ] || die "no temporary file: expected exit code 1, got $EXIT_CODE"
case "$ERR" in
*'unable to create a temporary file for $NVM_AUTH_HEADER'*) ;;
*) die "no temporary file: expected an error about the temporary file, got >$ERR<" ;;
esac
[ ! -e "$WORK/args" ] || die "no temporary file: expected wget not to run, got args >$(cat "$WORK/args")<"
# and stops there, before trying to write the config
[ "$(printf '%s\n' "$ERR" | tail -n 1)" = 'unable to create a temporary file for $NVM_AUTH_HEADER' ] || die "no temporary file: expected its error to be the last thing printed, got >$ERR<"
# given that $TMPDIR is unset or empty
for TMPDIR_STATE in unset empty; do
reset_logs
if [ "$TMPDIR_STATE" = unset ]; then
unset TMPDIR
else
TMPDIR=''
fi
nvm_wget_with_auth_header 'Bearer test-token' 'https://example.com/dist/index.tab'
EXIT_CODE=$?
TMPDIR="$WORK/tmp"
export TMPDIR
# then the config file goes in /tmp, and is removed afterwards
[ "$EXIT_CODE" = 0 ] || die "$TMPDIR_STATE \$TMPDIR: expected exit code 0, got $EXIT_CODE"
case "$(cat "$WORK/wgetrc-path")" in
/tmp/nvm-wgetrc.*) ;;
*) die "$TMPDIR_STATE \$TMPDIR: expected the config file in /tmp, got >$(cat "$WORK/wgetrc-path")<" ;;
esac
[ "$(cat "$WORK/wgetrc")" = 'header = Authorization: Bearer test-token' ] || die "$TMPDIR_STATE \$TMPDIR: expected only the header in the config, got >$(cat "$WORK/wgetrc")<"
[ ! -e "$(cat "$WORK/wgetrc-path")" ] || die "$TMPDIR_STATE \$TMPDIR: expected the config file to be removed, got $(cat "$WORK/wgetrc-path") left behind"
done
# given that the download is interrupted
for SIGNAL_CODE in HUP:129 INT:130 TERM:143; do
reset_logs
MOCK_KILL="${SIGNAL_CODE%%:*}"
nvm_wget_with_auth_header 'Bearer test-token' 'https://example.com/dist/index.tab'
EXIT_CODE=$?
unset MOCK_KILL
# then the config file is removed, and the signal's exit code is returned
[ "$EXIT_CODE" = "${SIGNAL_CODE#*:}" ] || die "SIG${SIGNAL_CODE%%:*}: expected exit code ${SIGNAL_CODE#*:}, got $EXIT_CODE"
assert_cleaned_up "SIG${SIGNAL_CODE%%:*}"
done
cleanup