diff --git a/doc/user-guide/src/environment-variables.md b/doc/user-guide/src/environment-variables.md index 7e836b034a..272269c220 100644 --- a/doc/user-guide/src/environment-variables.md +++ b/doc/user-guide/src/environment-variables.md @@ -72,6 +72,14 @@ - `RUSTUP_CONCURRENT_DOWNLOADS` _unstable_ (default: 2). Controls the number of downloads made concurrently. +- `RUSTUP_AUTHORIZATION_HEADER` (default: none). Sets the `Authorization` HTTP request header + that will be included in all downloads from the rustup distribution server. Useful for + authenticated downloads when using a private package index. + +- `RUSTUP_PROXY_AUTHORIZATION_HEADER` (default: none). Sets the `Proxy-Authorization` HTTP request header + that will be included in all downloads from the rustup distribution server that are made + through a proxy. Useful for authenticated proxy connections. + - `RUSTUP_TOOLCHAIN_SOURCE` _unstable_. Set by rustup to tell proxied tools how `RUSTUP_TOOLCHAIN` was determined. Non-rustup tools should not set this environment variable, except insofar as to mirror an earlier invocation from rustup. [directive syntax]: https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives diff --git a/rustup-init.sh b/rustup-init.sh index d1e43f5378..ca2dc03a2d 100755 --- a/rustup-init.sh +++ b/rustup-init.sh @@ -657,8 +657,51 @@ ignore() { "$@" } +# Succeeds if $1 is an http:// URL that points at the local machine: +# the host must be `localhost`, an IPv4 loopback address (127.0.0.0/8), or +# the IPv6 loopback address (`::1`). The resource at such a URL may be +# fetched over plain HTTP; every other URL must use https. +is_local_http_url() { + local _url="$1" + case "$_url" in + http://*) + ;; + *) + return 1 + ;; + esac + # Strip the path, the optional port, and IPv6 brackets to get the bare host. + local _host="${_url#http://}" + _host="${_host%%/*}" + case "$_host" in + \[*\]*) + # IPv6 literal host: [addr] or [addr]:port + _host="${_host%%\]*}" + _host="${_host#?}" + ;; + *) + _host="${_host%%:*}" + ;; + esac + case "$_host" in + localhost | 127.* | ::1) + return 0 + ;; + *) + return 1 + ;; + esac +} + # This wraps curl or wget. Try curl first, if not installed, # use wget instead. +# +# If RUSTUP_AUTHORIZATION_HEADER is set, it is sent as the value of the +# `Authorization` header, and if RUSTUP_PROXY_AUTHORIZATION_HEADER is set, +# it is sent as the value of the `Proxy-Authorization` header (to the +# proxy). The resource at an http:// URL that points at the local machine +# (see is_local_http_url) is fetched over plain HTTP; every other URL +# requires https. downloader() { # zsh does not split words by default, Required for curl retry arguments below. is_zsh && setopt local_options shwordsplit @@ -668,6 +711,7 @@ downloader() { local _err local _status local _retry + local _url if check_cmd curl; then # Check if we have a broken snap curl # https://github.com/boukendesho/curl-snap/issues/1 @@ -691,57 +735,105 @@ downloader() { _dld='curl or wget' # to be used in error message of need_cmd fi - if [ "$1" = --check ]; then + _url="$1" + + if [ "$_url" = --check ]; then need_cmd "$_dld" - elif [ "$_dld" = curl ]; then + return 0 + fi + + local _output + local _arch + _output="$2" + _arch="$3" + + if [ "$_dld" = curl ]; then + # Build the complete list of curl arguments in the positional + # parameters. POSIX sh has no arrays, and some values (cipher + # suites, header values) contain spaces, which would be split + # apart if carried in a single variable. check_curl_for_retry_support _retry="$RETVAL" - get_ciphersuites_for_curl - _ciphersuites="$RETVAL" - if [ -n "$_ciphersuites" ]; then - # shellcheck disable=SC2086 - _err=$(curl $_retry --proto '=https' --tlsv1.2 --ciphers "$_ciphersuites" --silent --show-error --fail --location "$1" --output "$2" 2>&1) - _status=$? + # shellcheck disable=SC2086 # _retry is intentionally split into words + set -- $_retry + if is_local_http_url "$_url"; then + # Plain HTTP to the local machine: no TLS enforcement is needed. + : else - warn "Not enforcing strong cipher suites for TLS, this is potentially less secure" - if ! check_help_for "$3" curl --proto --tlsv1.2; then - warn "Not enforcing TLS v1.2, this is potentially less secure" - # shellcheck disable=SC2086 - _err=$(curl $_retry --silent --show-error --fail --location "$1" --output "$2" 2>&1) - _status=$? + get_ciphersuites_for_curl + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + set -- "$@" --proto '=https' --tlsv1.2 --ciphers "$_ciphersuites" else - # shellcheck disable=SC2086 - _err=$(curl $_retry --proto '=https' --tlsv1.2 --silent --show-error --fail --location "$1" --output "$2" 2>&1) - _status=$? + warn "Not enforcing strong cipher suites for TLS, this is potentially less secure" + if check_help_for "$_arch" curl --proto --tlsv1.2; then + set -- "$@" --proto '=https' --tlsv1.2 + else + warn "Not enforcing TLS v1.2, this is potentially less secure" + fi fi fi + if [ -n "${RUSTUP_AUTHORIZATION_HEADER-}" ]; then + set -- "$@" --header "Authorization: ${RUSTUP_AUTHORIZATION_HEADER}" + fi + if [ -n "${RUSTUP_PROXY_AUTHORIZATION_HEADER-}" ]; then + set -- "$@" --proxy-header "Proxy-Authorization: ${RUSTUP_PROXY_AUTHORIZATION_HEADER}" + fi + set -- "$@" --silent --show-error --fail --location "$_url" --output "$_output" + _err=$(curl "$@" 2>&1) + _status=$? if [ -n "$_err" ]; then warn "$_err" if echo "$_err" | grep -q 404$; then - err "installer for platform '$3' not found, this may be unsupported" + err "installer for platform '$_arch' not found, this may be unsupported" exit 1 fi fi return $_status elif [ "$_dld" = wget ]; then + # Build the complete list of wget arguments in the positional + # parameters (see the curl branch above for why). + # + # wget has no option to send headers only to the proxy, so the + # Proxy-Authorization header is added with --header as well; it is + # included in the request that the proxy receives. + local _has_headers + _has_headers=no + set -- + if [ -n "${RUSTUP_AUTHORIZATION_HEADER-}" ]; then + set -- "$@" --header "Authorization: ${RUSTUP_AUTHORIZATION_HEADER}" + _has_headers=yes + fi + if [ -n "${RUSTUP_PROXY_AUTHORIZATION_HEADER-}" ]; then + set -- "$@" --header "Proxy-Authorization: ${RUSTUP_PROXY_AUTHORIZATION_HEADER}" + _has_headers=yes + fi if [ "$(wget -V 2>&1|head -2|tail -1|cut -f1 -d" ")" = "BusyBox" ]; then warn "using the BusyBox version of wget. Not enforcing strong cipher suites for TLS or TLS v1.2, this is potentially less secure" - _err=$(wget "$1" -O "$2" 2>&1) + if [ "$_has_headers" = yes ]; then + warn "BusyBox wget does not support custom headers, so RUSTUP_*_AUTHORIZATION_HEADER will not be sent" + set -- + fi + _err=$(wget "$@" "$_url" -O "$_output" 2>&1) _status=$? else get_ciphersuites_for_wget _ciphersuites="$RETVAL" - if [ -n "$_ciphersuites" ]; then - _err=$(wget --https-only --secure-protocol=TLSv1_2 --ciphers "$_ciphersuites" "$1" -O "$2" 2>&1) + if is_local_http_url "$_url"; then + # Plain HTTP to the local machine: no TLS enforcement is needed. + _err=$(wget "$@" "$_url" -O "$_output" 2>&1) + _status=$? + elif [ -n "$_ciphersuites" ]; then + _err=$(wget "$@" --https-only --secure-protocol=TLSv1_2 --ciphers "$_ciphersuites" "$_url" -O "$_output" 2>&1) _status=$? else warn "Not enforcing strong cipher suites for TLS, this is potentially less secure" - if ! check_help_for "$3" wget --https-only --secure-protocol; then + if ! check_help_for "$_arch" wget --https-only --secure-protocol; then warn "Not enforcing TLS v1.2, this is potentially less secure" - _err=$(wget "$1" -O "$2" 2>&1) + _err=$(wget "$@" "$_url" -O "$_output" 2>&1) _status=$? else - _err=$(wget --https-only --secure-protocol=TLSv1_2 "$1" -O "$2" 2>&1) + _err=$(wget "$@" --https-only --secure-protocol=TLSv1_2 "$_url" -O "$_output" 2>&1) _status=$? fi fi @@ -749,7 +841,7 @@ downloader() { if [ -n "$_err" ]; then warn "$_err" if echo "$_err" | grep -q ' 404 Not Found$'; then - err "installer for platform '$3' not found, this may be unsupported" + err "installer for platform '$_arch' not found, this may be unsupported" exit 1 fi fi diff --git a/src/download/mod.rs b/src/download/mod.rs index 8871a936f5..c6d91933b5 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -30,10 +30,12 @@ use crate::{dist::download::DownloadStatus, errors::RustupError, process::Proces #[cfg(test)] mod tests; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct DownloadOptions { tls: Tls, timeout: Duration, + authorization_header: Option, + proxy_authorization_header: Option, } impl DownloadOptions { @@ -44,7 +46,7 @@ impl DownloadOptions { hasher: None, status: None, resume: false, - options: *self, + options: self.clone(), } } } @@ -98,7 +100,15 @@ impl TryFrom<&Process> for DownloadOptions { Err(_) => 180, }); - Ok(Self { tls, timeout }) + let authorization_header = process.var_opt("RUSTUP_AUTHORIZATION_HEADER")?; + let proxy_authorization_header = process.var_opt("RUSTUP_PROXY_AUTHORIZATION_HEADER")?; + + Ok(Self { + tls, + timeout, + authorization_header, + proxy_authorization_header, + }) } } @@ -287,6 +297,14 @@ impl<'a> Download<'a> { } let mut req = client.get(url.as_str()); + + if let Some(header) = &self.options.authorization_header { + req = req.header(header::AUTHORIZATION, header); + } + if let Some(header) = &self.options.proxy_authorization_header { + req = req.header(header::PROXY_AUTHORIZATION, header); + } + if resume_from != 0 { req = req.header(header::RANGE, format!("bytes={resume_from}-")); } diff --git a/src/download/tests.rs b/src/download/tests.rs index 0957b3718b..e6438bb68f 100644 --- a/src/download/tests.rs +++ b/src/download/tests.rs @@ -28,12 +28,14 @@ mod reqwest { use reqwest::{Client, Proxy}; use url::Url; - use super::{scrub_env, serve_file, tmp_dir, write_file}; + use super::{scrub_env, serve_file, serve_file_with_header_verification, tmp_dir, write_file}; use crate::download::{DownloadOptions, Tls}; const OPTIONS: DownloadOptions = DownloadOptions { tls: DOWNLOAD_BACKEND, timeout: Duration::from_secs(180), + authorization_header: None, + proxy_authorization_header: None, }; #[cfg(feature = "reqwest-rustls-tls")] @@ -158,6 +160,8 @@ mod reqwest { DownloadOptions { tls: DOWNLOAD_BACKEND, timeout: Duration::from_secs(1), + authorization_header: None, + proxy_authorization_header: None, } .start(&from_url, &target_path) .with_resume() @@ -168,6 +172,136 @@ mod reqwest { assert!(target_path.exists(), "partial file should not be deleted"); assert_eq!(std::fs::read_to_string(&target_path).unwrap(), "123"); } + + #[tokio::test] + async fn authorization_header() { + let _guard = scrub_env().await; + let tmpdir = tmp_dir(); + let target_path = tmpdir.path().join("downloaded"); + + // Bearer token: ghp_1234567890abcdef follows the typical format of + // a GitHub Personal Access Token, which is commonly used as a + // Bearer token in CI/CD environments. + let bearer_token = "Bearer ghp_1234567890abcdef"; + let addr = serve_file_with_header_verification(vec![("Authorization", bearer_token)]); + let from_url = format!("http://{addr}").parse().unwrap(); + + let options = DownloadOptions { + tls: DOWNLOAD_BACKEND, + timeout: Duration::from_secs(180), + authorization_header: Some(bearer_token.to_string()), + proxy_authorization_header: None, + }; + + options + .start(&from_url, &target_path) + .download() + .await + .expect("Test download failed"); + + assert!(target_path.exists()); + assert_eq!( + std::fs::read_to_string(&target_path).unwrap().trim(), + "test content for header verification" + ); + } + + #[tokio::test] + async fn proxy_authorization_header() { + let _guard = scrub_env().await; + let tmpdir = tmp_dir(); + let target_path = tmpdir.path().join("downloaded"); + + // Basic auth value for username 'test' and password '123?45>6'; the + // password contains special characters (? and >) that are common in + // real-world passwords. + // Shell command: echo -n 'test:123?45>6' | base64 + // Result: dGVzdDoxMjM/NDU+Ng== + let basic_auth = "Basic dGVzdDoxMjM/NDU+Ng=="; + let addr = serve_file_with_header_verification(vec![("Proxy-Authorization", basic_auth)]); + let from_url = format!("http://{addr}").parse().unwrap(); + + let options = DownloadOptions { + tls: DOWNLOAD_BACKEND, + timeout: Duration::from_secs(180), + authorization_header: None, + proxy_authorization_header: Some(basic_auth.to_string()), + }; + + options + .start(&from_url, &target_path) + .download() + .await + .expect("Test download failed"); + + assert!(target_path.exists()); + assert_eq!( + std::fs::read_to_string(&target_path).unwrap().trim(), + "test content for header verification" + ); + } + + #[tokio::test] + async fn both_authorization_headers() { + let _guard = scrub_env().await; + let tmpdir = tmp_dir(); + let target_path = tmpdir.path().join("downloaded"); + + // Both Authorization and Proxy-Authorization headers + let addr = serve_file_with_header_verification(vec![ + ("Authorization", "Bearer combined-token"), + ("Proxy-Authorization", "Basic dGVzdDoxMjM/NDU+Ng=="), + ]); + let from_url = format!("http://{addr}").parse().unwrap(); + + let options = DownloadOptions { + tls: DOWNLOAD_BACKEND, + timeout: Duration::from_secs(180), + authorization_header: Some("Bearer combined-token".to_string()), + proxy_authorization_header: Some("Basic dGVzdDoxMjM/NDU+Ng==".to_string()), + }; + + options + .start(&from_url, &target_path) + .download() + .await + .expect("Test download failed"); + + assert!(target_path.exists()); + assert_eq!( + std::fs::read_to_string(&target_path).unwrap().trim(), + "test content for header verification" + ); + } + + #[tokio::test] + async fn no_authorization_headers() { + let _guard = scrub_env().await; + let tmpdir = tmp_dir(); + let target_path = tmpdir.path().join("downloaded"); + // Use the standard serve_file, which does not check for any headers + let addr = serve_file(b"test content for no headers".to_vec(), false); + let from_url = format!("http://{addr}").parse().unwrap(); + + let options = DownloadOptions { + tls: DOWNLOAD_BACKEND, + timeout: Duration::from_secs(180), + authorization_header: None, + proxy_authorization_header: None, + }; + + options + .start(&from_url, &target_path) + .download() + .await + .expect("Test download failed"); + + assert!(target_path.exists()); + assert_eq!( + std::fs::read_to_string(&target_path).unwrap().trim(), + "test content for no headers" + ); + } } pub fn tmp_dir() -> TempDir { @@ -310,3 +444,76 @@ async fn scrub_env() -> tokio::sync::MutexGuard<'static, ()> { guard } + +/// A server that verifies the given request headers match the expected values. +fn serve_file_with_header_verification(headers: Vec<(&str, &str)>) -> SocketAddr { + let addr: SocketAddr = ([127, 0, 0, 1], 0).into(); + let (addr_tx, addr_rx) = channel(); + let headers: Vec<(String, String)> = headers + .into_iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(); + + thread::spawn(move || { + let contents = b"test content for header verification".to_vec(); + + let svc = service_fn(move |req: Request| { + let contents = contents.clone(); + let headers = headers.clone(); + async move { + let res = serve_contents_with_header_verification(req, contents, &headers); + Ok::<_, Infallible>(res) + } + }); + + let rt = tokio::runtime::Runtime::new().expect("could not create Runtime"); + rt.block_on(async { + let listener = tokio::net::TcpListener::bind(addr) + .await + .expect("cannot bind"); + let local_addr = listener.local_addr().unwrap(); + addr_tx.send(local_addr).unwrap(); + + loop { + let (stream, _) = listener + .accept() + .await + .expect("could not accept connection"); + let io = hyper_util::rt::TokioIo::new(stream); + let svc_ref = svc.clone(); + + if let Err(err) = http1::Builder::new().serve_connection(io, svc_ref).await { + eprintln!("failed to serve connection: {err:?}"); + } + } + }); + }); + + addr_rx.recv().unwrap() +} + +fn serve_contents_with_header_verification( + req: Request, + contents: Vec, + headers: &[(String, String)], +) -> hyper::Response> { + // Verify all headers are present and have the expected values + for (header_name, expected_value) in headers { + let actual_value = req + .headers() + .get(header_name.as_str()) + .map(|v| v.to_str().unwrap_or("")); + if actual_value != Some(expected_value.as_str()) { + return hyper::Response::builder() + .status(hyper::StatusCode::UNAUTHORIZED) + .body(Full::new(Bytes::from("Unauthorized"))) + .unwrap(); + } + } + + hyper::Response::builder() + .status(hyper::StatusCode::OK) + .header(hyper::header::CONTENT_LENGTH, contents.len()) + .body(Full::new(Bytes::from(contents))) + .unwrap() +}