diff --git a/README.md b/README.md index eb564f62..8ae0b36a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/nvm.sh b/nvm.sh index 9ddd20e4..4d842ac0 100755 --- a/nvm.sh +++ b/nvm.sh @@ -219,11 +219,18 @@ nvm_download() { return 0 fi + # every local user can read a process's arguments (`ps`, /proc//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 \ diff --git a/test/fast/Unit tests/nvm_download keeps the Authorization header out of downloader arguments b/test/fast/Unit tests/nvm_download keeps the Authorization header out of downloader arguments new file mode 100755 index 00000000..87cb3022 --- /dev/null +++ b/test/fast/Unit tests/nvm_download keeps the Authorization header out of downloader arguments @@ -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 diff --git a/test/fast/Unit tests/nvm_wget_same_origin b/test/fast/Unit tests/nvm_wget_same_origin index e59de034..8b453b15 100755 --- a/test/fast/Unit tests/nvm_wget_same_origin +++ b/test/fast/Unit tests/nvm_wget_same_origin @@ -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>' +# 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) diff --git a/test/fast/Unit tests/nvm_wget_with_auth_header b/test/fast/Unit tests/nvm_wget_with_auth_header new file mode 100755 index 00000000..24f4b865 --- /dev/null +++ b/test/fast/Unit tests/nvm_wget_with_auth_header @@ -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