From e780535240ee3ba67048b434cec6f7f509ee7b31 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Sun, 18 Oct 2026 23:19:51 -0700 Subject: [PATCH] [Fix] `nvm_download`: with GNU Wget, follow only same-origin redirects when sending `$NVM_AUTH_HEADER` curl 7.58.0 and later drop a custom Authorization header when a redirect leads to another host, but wget sends `--header` along to every redirect target, so on a machine without curl, a mirror that redirected elsewhere handed the credential to that other host. When a header is sent with GNU Wget, run it with `--max-redirect=0`. When it then exits with 8 (an HTTP error, which includes a redirect it did not follow), read the `Location` with one more request, and re-request it only if it keeps the same scheme, host, and port, up to 20 times. A redirect to another origin, including a downgrade to `http`, fails with exit code 8 and an error that names the URL nvm requested and suggests curl. `--max-redirect` is not a BusyBox wget option, so other wgets are invoked as before; the README says so Discovered by Anthropic's OSS Scanner, as ANT-2026-PKUZHTT3; the scanner reported it as a vulnerability, but it is not one. --- README.md | 2 + nvm.sh | 80 +++++++ ...origin redirects with Authorization header | 92 ++++++++ test/fast/Unit tests/nvm_url_origin | 38 ++++ test/fast/Unit tests/nvm_wget_is_gnu | 90 ++++++++ test/fast/Unit tests/nvm_wget_same_origin | 209 ++++++++++++++++++ 6 files changed, 511 insertions(+) create mode 100755 test/fast/Unit tests/nvm_download wget follows only same-origin redirects with Authorization header create mode 100755 test/fast/Unit tests/nvm_url_origin create mode 100755 test/fast/Unit tests/nvm_wget_is_gnu create mode 100755 test/fast/Unit tests/nvm_wget_same_origin diff --git a/README.md b/README.md index f9f817f4..9cf93b16 100644 --- a/README.md +++ b/README.md @@ -669,6 +669,8 @@ NVM_AUTH_HEADER="Bearer secret-token" nvm install node The header is only sent with requests for URLs under a mirror, never to any other host. If exactly one of `$NVM_NODEJS_ORG_MIRROR` and `$NVM_IOJS_ORG_MIRROR` is set, only that mirror receives it; if both are set, both do; if neither is, the default mirrors (`https://nodejs.org/dist` and `https://iojs.org/dist`) do. +`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_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 254be976..83431490 100755 --- a/nvm.sh +++ b/nvm.sh @@ -220,12 +220,91 @@ nvm_download() { fi if [ -n "${sanitized_header}" ]; then + if [ "${NVM_DOWNLOADER}" = 'wget' ] && nvm_wget_is_gnu; then + nvm_wget_same_origin "${sanitized_header}" "$@" + return $? + fi set -- "$@" --header "Authorization: ${sanitized_header}" fi command "${NVM_DOWNLOADER}" "$@" } +# BusyBox wget rejects `--version`; GNU Wget2 reports itself as "GNU Wget2" +nvm_wget_is_gnu() { + command wget --version 2>/dev/null | nvm_grep -q '^GNU Wget ' +} + +# args: url; prints its `scheme://authority` +nvm_url_origin() { + local NVM_URL_REST + NVM_URL_REST="${1#*://}" + nvm_echo "${1%%://*}://${NVM_URL_REST%%/*}" +} + +# args: sanitized Authorization header value, wget arguments +# `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 +# 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 + for NVM_WGET_ARG in "$@"; do + case "${NVM_WGET_ARG}" in + http://* | https://*) NVM_WGET_URL="${NVM_WGET_ARG}" ;; + esac + done + local NVM_WGET_REQUESTED_URL + NVM_WGET_REQUESTED_URL="${NVM_WGET_URL}" + local NVM_WGET_EXIT_CODE + local NVM_WGET_LOCATION + local NVM_WGET_ARG_COUNT + local NVM_WGET_REDIRECTS + NVM_WGET_REDIRECTS=0 + while :; do + command wget --max-redirect=0 "$@" --header "${NVM_WGET_HEADER}" + 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 \ + | command awk 'tolower($1) == "location:" { print $2; exit }' | command tr -d '\r')" + case "${NVM_WGET_LOCATION}" in + '') return "${NVM_WGET_EXIT_CODE}" ;; + http://* | https://*) ;; + //*) NVM_WGET_LOCATION="${NVM_WGET_URL%%//*}${NVM_WGET_LOCATION}" ;; + /*) NVM_WGET_LOCATION="$(nvm_url_origin "${NVM_WGET_URL}")${NVM_WGET_LOCATION}" ;; + *) NVM_WGET_LOCATION="${NVM_WGET_URL%/*}/${NVM_WGET_LOCATION}" ;; + esac + if [ "$(nvm_url_origin "${NVM_WGET_LOCATION}")" != "$(nvm_url_origin "${NVM_WGET_URL}")" ]; then + nvm_err "${NVM_WGET_REQUESTED_URL} redirects to another origin; wget would send \$NVM_AUTH_HEADER there too, so it is not followed. Install curl to download from this mirror." + return "${NVM_WGET_EXIT_CODE}" + fi + NVM_WGET_REDIRECTS=$((NVM_WGET_REDIRECTS + 1)) + if [ "${NVM_WGET_REDIRECTS}" -gt 20 ]; then + nvm_err "${NVM_WGET_REQUESTED_URL}: too many redirects" + return "${NVM_WGET_EXIT_CODE}" + fi + NVM_WGET_ARG_COUNT=$# + for NVM_WGET_ARG in "$@"; do + if [ "${NVM_WGET_ARG}" = "${NVM_WGET_URL}" ]; then + set -- "$@" "${NVM_WGET_LOCATION}" + else + set -- "$@" "${NVM_WGET_ARG}" + fi + done + shift "${NVM_WGET_ARG_COUNT}" + NVM_WGET_URL="${NVM_WGET_LOCATION}" + done +} + # args: url # Whether `$NVM_AUTH_HEADER` applies to a request for the given URL: only URLs # under a mirror qualify, never any other host (eg, npmjs.org). When exactly one @@ -5578,6 +5657,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_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 wget follows only same-origin redirects with Authorization header b/test/fast/Unit tests/nvm_download wget follows only same-origin redirects with Authorization header new file mode 100755 index 00000000..114e3baa --- /dev/null +++ b/test/fast/Unit tests/nvm_download wget follows only same-origin redirects with Authorization header @@ -0,0 +1,92 @@ +#!/bin/sh + +WORK="$PWD/nvm_download-wget-redirect-work.$$" +TEST_BIN="$WORK/bin" +CALL_LOG="$WORK/calls.log" + +cleanup() { + unset -f die cleanup nvm_has_executable + unset NVM_NODEJS_ORG_MIRROR NVM_AUTH_HEADER WGET_VERSION + rm -rf "$WORK" + export PATH="$OLDPATH" +} +die () { echo "$@" ; cleanup ; exit 1; } + +\. ../../../nvm.sh + +\. ../../common.sh + +OLDPATH="$PATH" + +mkdir -p "$TEST_BIN" + +# fake wget: prints $WGET_VERSION for `--version`, logs every other call on one +# line, and answers like a mirror whose /same and /other paths redirect; with +# `--max-redirect=0` it stops there (exit 8) and, given `-S`, shows the Location +{ + echo '#!/bin/sh' + echo 'if [ "$1" = --version ]; then [ -n "${WGET_VERSION-}" ] && echo "${WGET_VERSION}"; exit 0; fi' + echo 'url=""; show=""; stop=""' + echo 'for a in "$@"; do case "$a" in http://*) url="$a" ;; -S) show=1 ;; --max-redirect=0) stop=1 ;; esac; done' + echo 'printf "%s\n" "$*" >> "$CALL_LOG"' + echo 'case "$url" in' + echo ' */same) [ -n "$stop" ] || exit 0; [ -z "$show" ] || echo " location: /dist/target" >&2; exit 8 ;;' + echo ' */other) [ -n "$stop" ] || exit 0; [ -z "$show" ] || echo " Location: http://elsewhere.example/dist/target" >&2; exit 8 ;;' + echo ' */missing) exit 8 ;;' + echo 'esac' +} > "$TEST_BIN/wget" +chmod +x "$TEST_BIN/wget" + +export CALL_LOG WGET_VERSION +export PATH="$TEST_BIN:$OLDPATH" +nvm_has_executable() { [ "$1" != curl ] && command -v "$1" >/dev/null 2>&1; } + +NVM_NODEJS_ORG_MIRROR='http://127.0.0.1:8080/dist' +export NVM_NODEJS_ORG_MIRROR + +# given GNU Wget and a credential +WGET_VERSION='GNU Wget 1.21.2 built on linux-gnu.' +NVM_AUTH_HEADER='Bearer test-token' +export NVM_AUTH_HEADER + +# when the mirror redirects within its own origin +: > "$CALL_LOG" +nvm_download -L -s 'http://127.0.0.1:8080/dist/same' -o - || die "a same-origin redirect was not followed; calls: $(cat "$CALL_LOG")" +# then wget is stopped at every redirect, and nvm follows it +grep -q 'http://127.0.0.1:8080/dist/target' "$CALL_LOG" || die "the same-origin redirect target was not requested; calls: $(cat "$CALL_LOG")" +! grep -v -e '--max-redirect=0' "$CALL_LOG" || die "wget was allowed to follow redirects itself; calls: $(cat "$CALL_LOG")" + +# when the mirror redirects to another origin +: > "$CALL_LOG" +try_err nvm_download -L -s 'http://127.0.0.1:8080/dist/other' -o - +# then the redirect is not followed, and the user is told why +[ "${CAPTURED_EXIT_CODE}" = 8 ] || die "a cross-origin redirect did not fail with wget's exit code 8; got ${CAPTURED_EXIT_CODE}" +! grep -q 'elsewhere.example' "$CALL_LOG" || die "the cross-origin redirect target was requested; calls: $(cat "$CALL_LOG")" +case "${CAPTURED_STDERR}" in + *'redirects to another origin'*) ;; + *) die "no error explained the unfollowed redirect; got: ${CAPTURED_STDERR}" ;; +esac + +# when the mirror answers with an error +: > "$CALL_LOG" +try_err nvm_download -L -s 'http://127.0.0.1:8080/dist/missing' -o - +# then that is the result, with no redirect error +[ "${CAPTURED_EXIT_CODE}" = 8 ] || die "an HTTP error did not exit with 8; got ${CAPTURED_EXIT_CODE}" +[ -z "${CAPTURED_STDERR}" ] || die "an HTTP error printed: ${CAPTURED_STDERR}" + +# given no credential, wget follows redirects itself as before +unset NVM_AUTH_HEADER +: > "$CALL_LOG" +nvm_download -L -s 'http://127.0.0.1:8080/dist/same' -o - || die 'an unauthenticated download did not succeed' +! grep -q -e '--max-redirect' "$CALL_LOG" || die "wget was stopped at redirects without a credential; calls: $(cat "$CALL_LOG")" + +# given a wget that is not GNU Wget (eg, BusyBox, which has no --max-redirect) +WGET_VERSION='' +NVM_AUTH_HEADER='Bearer test-token' +export NVM_AUTH_HEADER +: > "$CALL_LOG" +nvm_download -L -s 'http://127.0.0.1:8080/dist/same' -o - || die 'a non-GNU wget download did not succeed' +# then it gets no option it does not support +! grep -q -e '--max-redirect' "$CALL_LOG" || die "a non-GNU wget was given --max-redirect; calls: $(cat "$CALL_LOG")" + +cleanup diff --git a/test/fast/Unit tests/nvm_url_origin b/test/fast/Unit tests/nvm_url_origin new file mode 100755 index 00000000..fad2287b --- /dev/null +++ b/test/fast/Unit tests/nvm_url_origin @@ -0,0 +1,38 @@ +#!/bin/sh + +die () { echo "$@" ; exit 1; } + +\. ../../../nvm.sh + +check() { + ACTUAL="$(nvm_url_origin "${1}")" + [ "${ACTUAL}" = "${2}" ] || die "nvm_url_origin '${1}': expected >${2}<, got >${ACTUAL}<" +} + +# the path is dropped +check 'https://nodejs.org/dist' 'https://nodejs.org' +check 'https://nodejs.org/dist/v20.0.0/node-v20.0.0.tar.xz' 'https://nodejs.org' +check 'https://nodejs.org/' 'https://nodejs.org' +check 'http://mirror.example//dist' 'http://mirror.example' + +# a URL with no path is its own origin +check 'https://nodejs.org' 'https://nodejs.org' + +# the scheme and port are kept, so they take part in the comparison +check 'http://nodejs.org/dist' 'http://nodejs.org' +check 'http://127.0.0.1:8080/dist/target' 'http://127.0.0.1:8080' +check 'https://nodejs.org:443/dist' 'https://nodejs.org:443' +check 'https://[::1]:8080/dist' 'https://[::1]:8080' + +# the authority runs to the first `/` after the scheme, so a host spelled +# differently later in the URL does not change it +check 'https://mirror.example/http://elsewhere.example/dist' 'https://mirror.example' +check 'https://mirror.example/@elsewhere.example/dist' 'https://mirror.example' + +# userinfo is part of the authority +check 'https://user:pw@mirror.example/dist' 'https://user:pw@mirror.example' + +# anything before the first `/` is kept as is, so a lookalike authority differs +check 'https://mirror.example@elsewhere.example/dist' 'https://mirror.example@elsewhere.example' +check 'https://mirror.example.elsewhere.example/dist' 'https://mirror.example.elsewhere.example' +check 'https://mirror.example?@elsewhere.example/dist' 'https://mirror.example?@elsewhere.example' diff --git a/test/fast/Unit tests/nvm_wget_is_gnu b/test/fast/Unit tests/nvm_wget_is_gnu new file mode 100755 index 00000000..0cbe36a9 --- /dev/null +++ b/test/fast/Unit tests/nvm_wget_is_gnu @@ -0,0 +1,90 @@ +#!/bin/sh + +WORK="$PWD/nvm_wget_is_gnu-work.$$" +TEST_BIN="$WORK/bin" +NO_WGET_BIN="$WORK/no-wget-bin" + +cleanup() { + unset -f die cleanup check + unset WGET_VERSION_OUT WGET_VERSION_ERR WGET_VERSION_EXIT + rm -rf "$WORK" + export PATH="$OLDPATH" +} +die () { echo "$@" ; cleanup ; exit 1; } + +\. ../../../nvm.sh + +\. ../../common.sh + +OLDPATH="$PATH" + +mkdir -p "$TEST_BIN" "$NO_WGET_BIN" + +# `command wget` bypasses shell functions, so the fake must be an executable: +# for `--version` it prints $WGET_VERSION_OUT to stdout, $WGET_VERSION_ERR to +# stderr, and exits with $WGET_VERSION_EXIT; it fails on anything else +{ + echo '#!/bin/sh' + echo '[ "$#" -eq 1 ] && [ "$1" = --version ] || exit 99' + echo '[ -z "${WGET_VERSION_OUT-}" ] || printf "%s\n" "${WGET_VERSION_OUT}"' + echo '[ -z "${WGET_VERSION_ERR-}" ] || printf "%s\n" "${WGET_VERSION_ERR}" >&2' + echo 'exit "${WGET_VERSION_EXIT:-0}"' +} > "$TEST_BIN/wget" +chmod +x "$TEST_BIN/wget" + +export WGET_VERSION_OUT WGET_VERSION_ERR WGET_VERSION_EXIT +export PATH="$TEST_BIN:$OLDPATH" + +# args: expected exit code, description +check() { + try nvm_wget_is_gnu + [ "${CAPTURED_EXIT_CODE}" = "${1}" ] || die "nvm_wget_is_gnu with ${2}: expected exit code ${1}, got ${CAPTURED_EXIT_CODE}" + [ -z "${CAPTURED_STDOUT}" ] || die "nvm_wget_is_gnu with ${2}: expected no output, got >${CAPTURED_STDOUT}<" + try_err nvm_wget_is_gnu + [ -z "${CAPTURED_STDERR}" ] || die "nvm_wget_is_gnu with ${2}: expected no error output, got >${CAPTURED_STDERR}<" +} + +# GNU Wget +WGET_VERSION_OUT='GNU Wget 1.21.2 built on linux-gnu. + +-cares +digest -gpgme +https +ipv6 +iri +large-file -metalink +nls ++ntlm +opie +psl +ssl/openssl' +WGET_VERSION_ERR='' +WGET_VERSION_EXIT=0 +check 0 'GNU Wget 1.21.2' + +WGET_VERSION_OUT='GNU Wget 1.14 built on linux-gnu.' +check 0 'GNU Wget 1.14' + +# GNU Wget2 is a different program, and says so +WGET_VERSION_OUT='GNU Wget2 2.1.0 - multithreaded metalink/file/website downloader' +check 1 'GNU Wget2' + +# BusyBox wget rejects `--version`, on stderr, which is not shown +WGET_VERSION_OUT='' +WGET_VERSION_ERR="wget: unrecognized option '--version' +BusyBox v1.36.1 (2023-06-02 00:42:02 UTC) multi-call binary." +WGET_VERSION_EXIT=1 +check 1 'BusyBox wget' + +# "GNU Wget" on stderr does not count +WGET_VERSION_ERR='GNU Wget 1.21.2 built on linux-gnu.' +WGET_VERSION_EXIT=0 +check 1 '"GNU Wget" only on stderr' + +# "GNU Wget" must start a line +WGET_VERSION_ERR='' +WGET_VERSION_OUT='not GNU Wget 1.21.2' +check 1 '"GNU Wget" not at the start of a line' + +# no output at all +WGET_VERSION_OUT='' +check 1 'no version output' + +# no wget at all +ln -s "$(command -v grep)" "$NO_WGET_BIN/grep" +export PATH="$NO_WGET_BIN" +check 1 'no wget on the PATH' +export PATH="$TEST_BIN:$OLDPATH" + +cleanup diff --git a/test/fast/Unit tests/nvm_wget_same_origin b/test/fast/Unit tests/nvm_wget_same_origin new file mode 100755 index 00000000..e59de034 --- /dev/null +++ b/test/fast/Unit tests/nvm_wget_same_origin @@ -0,0 +1,209 @@ +#!/bin/sh + +WORK="$PWD/nvm_wget_same_origin-work.$$" +TEST_BIN="$WORK/bin" +CALL_LOG="$WORK/calls.log" + +cleanup() { + unset -f die cleanup run expect_calls + unset CALL_LOG + rm -rf "$WORK" + export PATH="$OLDPATH" +} +die () { echo "$@" ; cleanup ; exit 1; } + +\. ../../../nvm.sh + +\. ../../common.sh + +OLDPATH="$PATH" + +mkdir -p "$TEST_BIN" + +# `command wget` bypasses shell functions, so the fake must be an executable. +# It logs each call as one line of s, and answers like a mirror: a path +# that redirects exits with 8 under `--max-redirect=0` (and shows its Location +# given `-S`), and fails the test otherwise, since wget must never follow one. +{ + echo '#!/bin/sh' + echo 'url=""; show=""; stop=""' + echo 'for a in "$@"; do case "$a" in http://* | https://* | ftp://*) url="$a" ;; -S) show=1 ;; --max-redirect=0) stop=1 ;; esac; done' + echo 'printf "<%s>" "$@" >> "$CALL_LOG"; echo >> "$CALL_LOG"' + echo 'redirect() { [ -n "$stop" ] || exit 97; [ -z "$show" ] || printf " HTTP/1.1 302 Found\n Location: %s\n" "$1" >&2; exit 8; }' + echo 'case "$url" in' + echo ' ftp://*) exit 8 ;;' + echo ' */dist/target | */dist/ok) exit 0 ;;' + echo ' */dist/unreachable) exit 4 ;;' + echo ' */dist/missing | */dist/failure) [ -z "$show" ] || printf " HTTP/1.1 404 Not Found\n" >&2; exit 8 ;;' + echo ' */dist/absolute) redirect http://mirror.example/dist/target ;;' + echo ' https://mirror.example/dist/https-absolute) redirect https://mirror.example/dist/target ;;' + echo ' */dist/path-absolute) redirect /dist/target ;;' + echo ' */dist/scheme-relative) redirect //mirror.example/dist/target ;;' + echo ' */dist/relative) redirect target ;;' + echo ' */dist/crlf) [ -n "$stop" ] || exit 97; [ -z "$show" ] || printf " LOCATION: /dist/target\r\n" >&2; exit 8 ;;' + echo ' */dist/two-locations) [ -n "$stop" ] || exit 97; [ -z "$show" ] || printf " HTTP/1.1 302 Found\n Location: /dist/target\n Location: http://elsewhere.example/dist/target\n" >&2; exit 8 ;;' + echo ' */dist/hop1) redirect /dist/hop2 ;;' + echo ' */dist/hop2) redirect http://mirror.example/dist/target ;;' + echo ' */dist/to-failure) redirect /dist/failure ;;' + echo ' */dist/other-host) redirect http://elsewhere.example/dist/target ;;' + echo ' */dist/scheme-relative-other-host) redirect //elsewhere.example/dist/target ;;' + echo ' */dist/other-port) redirect http://mirror.example:8080/dist/target ;;' + echo ' https://mirror.example/dist/downgrade) redirect http://mirror.example/dist/target ;;' + echo ' */dist/lookalike) redirect http://mirror.example.elsewhere.example/dist/target ;;' + echo ' */dist/userinfo) redirect http://mirror.example@elsewhere.example/dist/target ;;' + echo ' */dist/hop-then-other-host) redirect /dist/other-host ;;' + echo ' */dist/loop) redirect /dist/loop ;;' + echo 'esac' + echo 'exit 98' +} > "$TEST_BIN/wget" +chmod +x "$TEST_BIN/wget" + +export CALL_LOG +export PATH="$TEST_BIN:$OLDPATH" + +# the Authorization header goes along with every request +HEADER='<--header>' +run() { + : > "$CALL_LOG" + try_err nvm_wget_same_origin 'Bearer test-token' "$@" +} + +# args: description, expected calls (one per argument) +expect_calls() { + EXPECTED_DESCRIPTION="${1}" + shift + EXPECTED_CALLS="$(printf '%s\n' "$@")" + ACTUAL_CALLS="$(cat "$CALL_LOG")" + [ "${ACTUAL_CALLS}" = "${EXPECTED_CALLS}" ] || die "${EXPECTED_DESCRIPTION}: expected calls +${EXPECTED_CALLS} +got +${ACTUAL_CALLS}" +} + +# a download that does not redirect is made once, with wget stopped at redirects +run -q -O - 'http://mirror.example/dist/ok' +[ "${CAPTURED_EXIT_CODE}" = 0 ] || die "a successful download: expected exit code 0, got ${CAPTURED_EXIT_CODE}" +[ -z "${CAPTURED_STDERR}" ] || die "a successful download: expected no error, got >${CAPTURED_STDERR}<" +expect_calls 'a successful download' \ + "<--max-redirect=0><-q><-O><->${HEADER}" + +# any failure but an HTTP error is returned as is, with no Location lookup +run -q -O - 'http://mirror.example/dist/unreachable' +[ "${CAPTURED_EXIT_CODE}" = 4 ] || die "a network failure: expected exit code 4, got ${CAPTURED_EXIT_CODE}" +[ -z "${CAPTURED_STDERR}" ] || die "a network failure: expected no error, got >${CAPTURED_STDERR}<" +expect_calls 'a network failure' \ + "<--max-redirect=0><-q><-O><->${HEADER}" + +# an HTTP error with no Location is returned as is +run -q -O - 'http://mirror.example/dist/missing' +[ "${CAPTURED_EXIT_CODE}" = 8 ] || die "an HTTP error: expected exit code 8, got ${CAPTURED_EXIT_CODE}" +[ -z "${CAPTURED_STDERR}" ] || die "an HTTP error: expected no error, got >${CAPTURED_STDERR}<" +expect_calls 'an HTTP error' \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" + +# with no http(s) URL in the arguments, there is nothing to look up +run -q -O - 'ftp://mirror.example/dist/missing' +[ "${CAPTURED_EXIT_CODE}" = 8 ] || die "no http(s) URL: expected exit code 8, got ${CAPTURED_EXIT_CODE}" +[ -z "${CAPTURED_STDERR}" ] || die "no http(s) URL: expected no error, got >${CAPTURED_STDERR}<" +expect_calls 'no http(s) URL' \ + "<--max-redirect=0><-q><-O><->${HEADER}" + +# a same-origin redirect is followed, with the URL replaced in place and every other argument kept +for LOCATION_KIND in absolute path-absolute scheme-relative relative crlf; do + run -q -O - 'http://mirror.example/dist/'"${LOCATION_KIND}" --tries=1 + [ "${CAPTURED_EXIT_CODE}" = 0 ] || die "a same-origin ${LOCATION_KIND} redirect: expected exit code 0, got ${CAPTURED_EXIT_CODE}; stderr >${CAPTURED_STDERR}<" + [ -z "${CAPTURED_STDERR}" ] || die "a same-origin ${LOCATION_KIND} redirect: expected no error, got >${CAPTURED_STDERR}<" + expect_calls "a same-origin ${LOCATION_KIND} redirect" \ + "<--max-redirect=0><-q><-O><-><--tries=1>${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" \ + "<--max-redirect=0><-q><-O><-><--tries=1>${HEADER}" +done + +# a doubled slash in the path does not change where a path-absolute or scheme-relative Location resolves +for LOCATION_KIND in path-absolute scheme-relative; do + run -q -O - 'http://mirror.example//dist/'"${LOCATION_KIND}" + [ "${CAPTURED_EXIT_CODE}" = 0 ] || die "a same-origin ${LOCATION_KIND} redirect from a path with a doubled slash: expected exit code 0, got ${CAPTURED_EXIT_CODE}; stderr >${CAPTURED_STDERR}<" + [ -z "${CAPTURED_STDERR}" ] || die "a same-origin ${LOCATION_KIND} redirect from a path with a doubled slash: expected no error, got >${CAPTURED_STDERR}<" + expect_calls "a same-origin ${LOCATION_KIND} redirect from a path with a doubled slash" \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" \ + "<--max-redirect=0><-q><-O><->${HEADER}" +done + +# only the first Location header is used +run -q -O - 'http://mirror.example/dist/two-locations' +[ "${CAPTURED_EXIT_CODE}" = 0 ] || die "a redirect with two Location headers: expected exit code 0, got ${CAPTURED_EXIT_CODE}; stderr >${CAPTURED_STDERR}<" +[ -z "${CAPTURED_STDERR}" ] || die "a redirect with two Location headers: expected no error, got >${CAPTURED_STDERR}<" +expect_calls 'a redirect with two Location headers' \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" \ + "<--max-redirect=0><-q><-O><->${HEADER}" + +# https stays https +run -q -O - 'https://mirror.example/dist/https-absolute' +[ "${CAPTURED_EXIT_CODE}" = 0 ] || die "a same-origin https redirect: expected exit code 0, got ${CAPTURED_EXIT_CODE}; stderr >${CAPTURED_STDERR}<" +expect_calls 'a same-origin https redirect' \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" \ + "<--max-redirect=0><-q><-O><->${HEADER}" + +# a chain of same-origin redirects is followed, each Location looked up at the current URL +run -q -O - 'http://mirror.example/dist/hop1' +[ "${CAPTURED_EXIT_CODE}" = 0 ] || die "a chain of same-origin redirects: expected exit code 0, got ${CAPTURED_EXIT_CODE}; stderr >${CAPTURED_STDERR}<" +expect_calls 'a chain of same-origin redirects' \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" \ + "<--max-redirect=0><-q><-O><->${HEADER}" + +# the redirect target's own HTTP error is the result +run -q -O - 'http://mirror.example/dist/to-failure' +[ "${CAPTURED_EXIT_CODE}" = 8 ] || die "a redirect to an HTTP error: expected exit code 8, got ${CAPTURED_EXIT_CODE}" +[ -z "${CAPTURED_STDERR}" ] || die "a redirect to an HTTP error: expected no error, got >${CAPTURED_STDERR}<" +expect_calls 'a redirect to an HTTP error' \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" + +# a redirect to another origin is not followed, so the header never reaches it +for REDIRECT in \ + 'http://mirror.example/dist/other-host' \ + 'http://mirror.example/dist/scheme-relative-other-host' \ + 'http://mirror.example/dist/other-port' \ + 'https://mirror.example/dist/downgrade' \ + 'http://mirror.example/dist/lookalike' \ + 'http://mirror.example/dist/userinfo' \ +; do + run -q -O - "${REDIRECT}" + [ "${CAPTURED_EXIT_CODE}" = 8 ] || die "a cross-origin redirect from ${REDIRECT}: expected exit code 8, got ${CAPTURED_EXIT_CODE}" + [ "${CAPTURED_STDERR}" = "${REDIRECT} redirects to another origin; wget would send \$NVM_AUTH_HEADER there too, so it is not followed. Install curl to download from this mirror." ] \ + || die "a cross-origin redirect from ${REDIRECT}: expected an error naming it, got >${CAPTURED_STDERR}<" + expect_calls "a cross-origin redirect from ${REDIRECT}" \ + "<--max-redirect=0><-q><-O><-><${REDIRECT}>${HEADER}" \ + "<--max-redirect=0><-q><-S><-O><${REDIRECT}>${HEADER}" +done + +# a cross-origin redirect after a same-origin one names the URL that was requested +run -q -O - 'http://mirror.example/dist/hop-then-other-host' +[ "${CAPTURED_EXIT_CODE}" = 8 ] || die "a cross-origin redirect after a same-origin one: expected exit code 8, got ${CAPTURED_EXIT_CODE}" +[ "${CAPTURED_STDERR}" = "http://mirror.example/dist/hop-then-other-host redirects to another origin; wget would send \$NVM_AUTH_HEADER there too, so it is not followed. Install curl to download from this mirror." ] \ + || die "a cross-origin redirect after a same-origin one: expected an error naming the requested URL, got >${CAPTURED_STDERR}<" +expect_calls 'a cross-origin redirect after a same-origin one' \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" \ + "<--max-redirect=0><-q><-O><->${HEADER}" \ + "<--max-redirect=0><-q><-S><-O>${HEADER}" + +# at most 20 redirects are followed +run -q -O - 'http://mirror.example/dist/loop' +[ "${CAPTURED_EXIT_CODE}" = 8 ] || die "a redirect loop: expected exit code 8, got ${CAPTURED_EXIT_CODE}" +[ "${CAPTURED_STDERR}" = 'http://mirror.example/dist/loop: too many redirects' ] || die "a redirect loop: expected a too many redirects error, got >${CAPTURED_STDERR}<" +DOWNLOADS="$(command grep -c -e '^<--max-redirect=0><-q><-O><->' "$CALL_LOG")" +[ "${DOWNLOADS}" = 21 ] || die "a redirect loop: expected the request and 20 redirects, got ${DOWNLOADS} requests" +LOOKUPS="$(command grep -c -e '^<--max-redirect=0><-q><-S><-O>' "$CALL_LOG")" +[ "${LOOKUPS}" = 21 ] || die "a redirect loop: expected 21 Location lookups, got ${LOOKUPS}" + +cleanup