1
0
mirror of https://github.com/nvm-sh/nvm.git synced 2026-10-27 09:13:43 +08:00

[Fix] nvm_download: only send $NVM_AUTH_HEADER to URLs under a mirror it applies to

The header was added to every download,
whatever the host.
With only `$NVM_NODEJS_ORG_MIRROR` set,
a private mirror's credential therefore also went to the default `https://iojs.org/dist`
(eg, from `nvm ls-remote`),
and with only `$NVM_IOJS_ORG_MIRROR` set,
to `https://nodejs.org/dist`;
it also went to `npmjs.org` when installing an old npm.

Add `nvm_auth_header_applies_to`,
which uses `nvm_get_mirror` and accepts a URL only when it is under a mirror,
compared literally,
with a `/` after the mirror's prefix.
When exactly one of the two mirror variables is set,
the header belongs to that mirror alone;
when both or neither are set,
both mirrors
(including the defaults, when neither is)
still receive it,
so the documented example keeps working.
An invalid mirror matches nothing,
and no empty `Authorization:` header is sent when sanitizing leaves nothing

Discovered by Anthropic's OSS Scanner, as ANT-2026-YPW87EDH; the scanner reported it as a vulnerability, but it is not one.
This commit is contained in:
Jordan Harband
2026-10-18 23:19:14 -07:00
parent 6bbbf3e8d3
commit e3aa232901
4 changed files with 262 additions and 3 deletions

View File

@@ -667,6 +667,8 @@ To pass an Authorization header through to the mirror url, set `$NVM_AUTH_HEADER
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.
`$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

53
nvm.sh
View File

@@ -156,7 +156,24 @@ nvm_download() {
local sanitized_header
sanitized_header=''
if [ -n "${NVM_AUTH_HEADER:-}" ]; then
sanitized_header="$(nvm_sanitize_auth_header "${NVM_AUTH_HEADER}")"
local NVM_DOWNLOAD_AUTH
NVM_DOWNLOAD_AUTH=''
local NVM_DOWNLOAD_ARG
for NVM_DOWNLOAD_ARG in "$@"; do
case "${NVM_DOWNLOAD_ARG}" in
http://* | https://*)
if nvm_auth_header_applies_to "${NVM_DOWNLOAD_ARG}"; then
NVM_DOWNLOAD_AUTH=1
else
NVM_DOWNLOAD_AUTH=''
break
fi
;;
esac
done
if [ -n "${NVM_DOWNLOAD_AUTH}" ]; then
sanitized_header="$(nvm_sanitize_auth_header "${NVM_AUTH_HEADER}")"
fi
fi
local NVM_DOWNLOADER
@@ -202,13 +219,43 @@ nvm_download() {
return 0
fi
if [ -n "${NVM_AUTH_HEADER:-}" ]; then
if [ -n "${sanitized_header}" ]; then
set -- "$@" --header "Authorization: ${sanitized_header}"
fi
command "${NVM_DOWNLOADER}" "$@"
}
# 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
# of `$NVM_NODEJS_ORG_MIRROR` and `$NVM_IOJS_ORG_MIRROR` is set, the header is
# that mirror's credential, so the other flavor's default host does not get it.
nvm_auth_header_applies_to() {
local NVM_AUTH_FLAVOR
local NVM_AUTH_MIRROR
for NVM_AUTH_FLAVOR in node iojs; do
case "${NVM_AUTH_FLAVOR}" in
node)
if [ -z "${NVM_NODEJS_ORG_MIRROR-}" ] && [ -n "${NVM_IOJS_ORG_MIRROR-}" ]; then
continue
fi
;;
iojs)
if [ -z "${NVM_IOJS_ORG_MIRROR-}" ] && [ -n "${NVM_NODEJS_ORG_MIRROR-}" ]; then
continue
fi
;;
esac
# an invalid mirror matches nothing; the download itself reports it
NVM_AUTH_MIRROR="$(nvm_get_mirror "${NVM_AUTH_FLAVOR}" std 2>/dev/null)" || continue
case "${1-}" in
"${NVM_AUTH_MIRROR}"/*) return 0 ;;
esac
done
return 1
}
nvm_sanitize_auth_header() {
# Remove potentially dangerous characters; allow the full base64 (A-Za-z0-9+/=)
# and base64url (A-Za-z0-9-_=) charsets, plus the space, colon, dot, and
@@ -5530,7 +5577,7 @@ nvm() {
nvm_replace_literal nvm_sanitize_path nvm_sanitize_url nvm_has_colors nvm_has_italics nvm_process_parameters \
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_npmrc_bad_news_bears nvm_sanitize_auth_header nvm_auth_header_applies_to \
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 \

View File

@@ -0,0 +1,128 @@
#!/bin/sh
die () { echo "$@" ; exit 1; }
\. ../../../nvm.sh
unset NVM_NODEJS_ORG_MIRROR NVM_IOJS_ORG_MIRROR
# args: url; asserts it applies, with exit code 0 and no output
applies() {
OUTPUT="$(nvm_auth_header_applies_to "${1}" 2>&1)"
EXIT_CODE=$?
[ "${EXIT_CODE}" = 0 ] || die "nvm_auth_header_applies_to '${1}' (node mirror >${NVM_NODEJS_ORG_MIRROR-(unset)}<, iojs mirror >${NVM_IOJS_ORG_MIRROR-(unset)}<): expected exit code 0, got ${EXIT_CODE}"
[ -z "${OUTPUT}" ] || die "nvm_auth_header_applies_to '${1}': expected no output, got >${OUTPUT}<"
}
# args: url; asserts it does not apply, with exit code 1 and no output
does_not_apply() {
OUTPUT="$(nvm_auth_header_applies_to "${1}" 2>&1)"
EXIT_CODE=$?
[ "${EXIT_CODE}" = 1 ] || die "nvm_auth_header_applies_to '${1}' (node mirror >${NVM_NODEJS_ORG_MIRROR-(unset)}<, iojs mirror >${NVM_IOJS_ORG_MIRROR-(unset)}<): expected exit code 1, got ${EXIT_CODE}"
[ -z "${OUTPUT}" ] || die "nvm_auth_header_applies_to '${1}': expected no output, got >${OUTPUT}<"
}
# no argument, or an empty one, never applies
OUTPUT="$(nvm_auth_header_applies_to 2>&1)"
EXIT_CODE=$?
[ "${EXIT_CODE}" = 1 ] || die "no argument: expected exit code 1, got ${EXIT_CODE}"
[ -z "${OUTPUT}" ] || die "no argument: expected no output, got >${OUTPUT}<"
does_not_apply ''
# neither mirror set: both defaults apply, nothing else does
applies 'https://nodejs.org/dist/index.tab'
applies 'https://nodejs.org/dist/v22.0.0/node-v22.0.0-linux-x64.tar.xz'
applies 'https://iojs.org/dist/index.tab'
does_not_apply 'https://nodejs.org/dist'
does_not_apply 'https://nodejs.org/distx/index.tab'
does_not_apply 'https://nodejs.org.example.com/dist/index.tab'
does_not_apply 'https://nodejs.org/download/index.tab'
does_not_apply 'http://nodejs.org/dist/index.tab'
does_not_apply 'HTTPS://NODEJS.ORG/dist/index.tab'
does_not_apply 'https://npmjs.org/install.sh'
does_not_apply 'https://registry.npmjs.org/npm/-/npm-1.4.28.tgz'
does_not_apply 'x https://nodejs.org/dist/index.tab'
# an empty mirror variable counts as unset
NVM_NODEJS_ORG_MIRROR=''
NVM_IOJS_ORG_MIRROR=''
applies 'https://nodejs.org/dist/index.tab'
applies 'https://iojs.org/dist/index.tab'
# only the node mirror set (the iojs one empty or unset): only it applies
NVM_NODEJS_ORG_MIRROR='https://mirror.example.com/node'
applies 'https://mirror.example.com/node/index.tab'
does_not_apply 'https://iojs.org/dist/index.tab'
does_not_apply 'https://nodejs.org/dist/index.tab'
does_not_apply 'https://mirror.example.com/nodex/index.tab'
does_not_apply 'https://mirror.example.com/iojs/index.tab'
unset NVM_IOJS_ORG_MIRROR
applies 'https://mirror.example.com/node/index.tab'
does_not_apply 'https://iojs.org/dist/index.tab'
# only the iojs mirror set (the node one empty or unset): only it applies
NVM_NODEJS_ORG_MIRROR=''
NVM_IOJS_ORG_MIRROR='https://mirror.example.com/iojs'
applies 'https://mirror.example.com/iojs/index.tab'
does_not_apply 'https://nodejs.org/dist/index.tab'
does_not_apply 'https://iojs.org/dist/index.tab'
unset NVM_NODEJS_ORG_MIRROR
applies 'https://mirror.example.com/iojs/index.tab'
does_not_apply 'https://nodejs.org/dist/index.tab'
# both set: each applies, the defaults do not
NVM_NODEJS_ORG_MIRROR='https://mirror.example.com/node'
NVM_IOJS_ORG_MIRROR='http://127.0.0.1:8080/iojs'
applies 'https://mirror.example.com/node/index.tab'
applies 'http://127.0.0.1:8080/iojs/index.tab'
does_not_apply 'https://nodejs.org/dist/index.tab'
does_not_apply 'https://iojs.org/dist/index.tab'
does_not_apply 'https://127.0.0.1:8080/iojs/index.tab'
# both set, one invalid: the invalid one matches nothing (silently), the other still applies
NVM_NODEJS_ORG_MIRROR='ftp://mirror.example.com/node'
applies 'http://127.0.0.1:8080/iojs/index.tab'
does_not_apply 'ftp://mirror.example.com/node/index.tab'
does_not_apply 'https://nodejs.org/dist/index.tab'
# only an invalid mirror set: nothing applies, not even the defaults
unset NVM_IOJS_ORG_MIRROR
for INVALID in 'ftp://mirror.example.com/node' 'https://mirror.example.com/a b' 'https://mirror.example.com/$(id)' 'https://mirror.example.com/?q=1' 'mirror.example.com/node'; do
NVM_NODEJS_ORG_MIRROR="${INVALID}"
does_not_apply "${INVALID}/index.tab"
does_not_apply 'https://nodejs.org/dist/index.tab'
does_not_apply 'https://iojs.org/dist/index.tab'
# an invalid mirror must not act as an empty prefix
does_not_apply '/index.tab'
does_not_apply '/'
done
# a mirror with no path: only its own host applies, not a host it prefixes
NVM_NODEJS_ORG_MIRROR='https://mirror.example.com'
applies 'https://mirror.example.com/v22.0.0/SHASUMS256.txt'
does_not_apply 'https://mirror.example.com.evil.example/v22.0.0/SHASUMS256.txt'
does_not_apply 'https://mirror.example.com:8443/v22.0.0/SHASUMS256.txt'
does_not_apply 'https://mirror.example.com@evil.example/v22.0.0/SHASUMS256.txt'
# a mirror with userinfo and a port is compared as written
NVM_NODEJS_ORG_MIRROR='https://user:pw@mirror.example.com:8443/node'
applies 'https://user:pw@mirror.example.com:8443/node/index.tab'
does_not_apply 'https://mirror.example.com:8443/node/index.tab'
# a mirror with a trailing slash applies to the URLs nvm builds from it
NVM_NODEJS_ORG_MIRROR='https://mirror.example.com/node/'
applies 'https://mirror.example.com/node//index.tab'
does_not_apply 'https://mirror.example.com/node/index.tab'
# glob characters in a mirror match only literally
NVM_NODEJS_ORG_MIRROR='http://[::1]:8080/[n]ode'
applies 'http://[::1]:8080/[n]ode/index.tab'
does_not_apply 'http://[::1]:8080/node/index.tab'
does_not_apply 'http://1:8080/[n]ode/index.tab'
# the mirror variables are left as they were, and no helper variables leak
[ "${NVM_NODEJS_ORG_MIRROR}" = 'http://[::1]:8080/[n]ode' ] || die "expected NVM_NODEJS_ORG_MIRROR to be unchanged, got >${NVM_NODEJS_ORG_MIRROR}<"
[ -z "${NVM_IOJS_ORG_MIRROR+set}" ] || die "expected NVM_IOJS_ORG_MIRROR to stay unset, got >${NVM_IOJS_ORG_MIRROR}<"
nvm_auth_header_applies_to 'http://[::1]:8080/[n]ode/index.tab' || die 'expected the glob mirror to apply when called in the current shell'
[ -z "${NVM_AUTH_FLAVOR+set}" ] || die "expected NVM_AUTH_FLAVOR not to leak, got >${NVM_AUTH_FLAVOR}<"
[ -z "${NVM_AUTH_MIRROR+set}" ] || die "expected NVM_AUTH_MIRROR not to leak, got >${NVM_AUTH_MIRROR}<"

View File

@@ -0,0 +1,82 @@
#!/bin/sh
WORK="$PWD/nvm_download-auth-mirror-work.$$"
TEST_BIN="$WORK/bin"
REQUEST_LOG="$WORK/request.log"
cleanup() {
unset -f die cleanup sends_header
unset NVM_NODEJS_ORG_MIRROR NVM_IOJS_ORG_MIRROR
rm -rf "$WORK"
export PATH="$OLDPATH"
}
die () { echo "$@" ; cleanup ; exit 1; }
\. ../../../nvm.sh
\. ../../common.sh
OLDPATH="$PATH"
# fake curl: record every argument, and anything given on stdin, then succeed
make_fake_curl "$TEST_BIN" '{ for a in "$@"; do printf "%s\n" "$a"; done; cat; } > "$REQUEST_LOG"'
VERSION_MESSAGE="curl 7.99.0 (fake)"
export REQUEST_LOG
export PATH="$TEST_BIN:$OLDPATH"
# args: url; whether nvm_download handed the credential to curl for that url
sends_header() {
NVM_AUTH_HEADER='Bearer test-token' nvm_download -L -s "$1" -o - < /dev/null || die "nvm_download returned nonzero for $1"
grep -Fq 'test-token' "$REQUEST_LOG"
}
# given no mirror is configured
unset NVM_NODEJS_ORG_MIRROR NVM_IOJS_ORG_MIRROR
# then both default mirrors are sent the header, and nothing else is
sends_header 'https://nodejs.org/dist/index.tab' || die 'no mirror: header not sent to the default node mirror'
sends_header 'https://iojs.org/dist/index.tab' || die 'no mirror: header not sent to the default iojs mirror'
! sends_header 'https://npmjs.org/install.sh' || die 'no mirror: header sent to npmjs.org'
! sends_header 'https://nodejs.org.example.com/dist/index.tab' || die 'no mirror: header sent to a host that merely starts with the mirror host'
! sends_header 'https://nodejs.org/distx/index.tab' || die 'no mirror: header sent to a path that merely starts with the mirror path'
# given only the node mirror is configured
NVM_NODEJS_ORG_MIRROR='http://127.0.0.1:8080/dist'
export NVM_NODEJS_ORG_MIRROR
# then only that mirror is sent the header
sends_header 'http://127.0.0.1:8080/dist/index.tab' || die 'node mirror: header not sent to it'
sends_header 'http://127.0.0.1:8080/dist/v22.0.0/SHASUMS256.txt' || die 'node mirror: header not sent to a path under it'
! sends_header 'https://iojs.org/dist/index.tab' || die 'node mirror: header sent to the default iojs mirror'
! sends_header 'https://nodejs.org/dist/index.tab' || die 'node mirror: header sent to the default node mirror'
! sends_header 'http://127.0.0.1:8080/distx/index.tab' || die 'node mirror: header sent outside the mirror path'
! sends_header 'https://npmjs.org/install.sh' || die 'node mirror: header sent to npmjs.org'
# given only the iojs mirror is configured
unset NVM_NODEJS_ORG_MIRROR
NVM_IOJS_ORG_MIRROR='http://127.0.0.1:8080/iojs'
export NVM_IOJS_ORG_MIRROR
# then only that mirror is sent the header
sends_header 'http://127.0.0.1:8080/iojs/index.tab' || die 'iojs mirror: header not sent to it'
! sends_header 'https://nodejs.org/dist/index.tab' || die 'iojs mirror: header sent to the default node mirror'
! sends_header 'https://iojs.org/dist/index.tab' || die 'iojs mirror: header sent to the default iojs mirror'
# given both mirrors are configured
NVM_NODEJS_ORG_MIRROR='http://127.0.0.1:8080/dist'
export NVM_NODEJS_ORG_MIRROR
# then both are sent the header, and the default hosts are not
sends_header 'http://127.0.0.1:8080/dist/index.tab' || die 'both mirrors: header not sent to the node mirror'
sends_header 'http://127.0.0.1:8080/iojs/index.tab' || die 'both mirrors: header not sent to the iojs mirror'
! sends_header 'https://nodejs.org/dist/index.tab' || die 'both mirrors: header sent to the default node mirror'
! sends_header 'https://iojs.org/dist/index.tab' || die 'both mirrors: header sent to the default iojs mirror'
# given an invalid mirror, nothing is sent the header (rather than everything)
NVM_NODEJS_ORG_MIRROR='ftp://127.0.0.1/dist'
unset NVM_IOJS_ORG_MIRROR
! sends_header 'https://nodejs.org/dist/index.tab' || die 'invalid mirror: header sent to the default node mirror'
! sends_header 'https://iojs.org/dist/index.tab' || die 'invalid mirror: header sent to the default iojs mirror'
# given a mirror whose name has glob characters, they match only literally
NVM_NODEJS_ORG_MIRROR='http://127.0.0.1:8080/[d]ist'
! sends_header 'http://127.0.0.1:8080/dist/index.tab' || die 'glob mirror: header sent to a URL matching the mirror as a pattern'
sends_header 'http://127.0.0.1:8080/[d]ist/index.tab' || die 'glob mirror: header not sent to the mirror itself'
cleanup