Files
nvm/test/fast/Unit tests/nvm_wget_with_auth_header
T
Jordan Harband 903e349369 [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.
2026-10-17 22:28:01 -07:00

188 lines
7.9 KiB
Bash
Executable File

#!/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