CVE-2024-37150: Insufficient Information (NVD-CWE-noinfo)
value: An issue in `.npmrc` support in Deno 1.44.0 was discovered where Deno would send `.npmrc` credentials for the scope to the tarball URL when the registry provided URLs for a tarball on a different domain. All users relying on .npmrc are potentially affected by this vulnerability if their private registry references tarball URLs at a different domain. This includes usage of deno install subcommand, auto-install for npm: specifiers and LSP usage. It is recommended to upgrade to Deno 1.44.1 and if your private registry ever serves tarballs at a different domain to rotate your registry credentials.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-06-06T16:15Z
Repository: https://github.com/denoland/deno
Language: Other
Files changed: Cargo.lock, cli/Cargo.toml, cli/args/mod.rs, cli/http_util.rs, cli/npm/managed/cache/tarball.rs, tests/integration/lsp_tests.rs, tests/registry/npm-private/@denotest/tarballs-privateserver2/1.0.0/index.js, tests/registry/npm-private/@denotest/tarballs-privateserver2/1.0.0/package.json, tests/registry/npm-private2/@denotest/tarballs-privateserver2/1.0.0/index.js, tests/registry/npm-private2/@denotest/tarballs-privateserver2/1.0.0/package.json, tests/registry/npm/@denotest/tarballs-privateserver2/1.0.0/index.js, tests/registry/npm/@denotest/tarballs-privateserver2/1.0.0/package.json, tests/specs/compile/npmrc/.npmrc, tests/specs/compile/npmrc/install.out, tests/specs/npm/npmrc/.npmrc, tests/specs/npm/npmrc/install.out, tests/specs/npm/npmrc_bad_registry_config/.npmrc, tests/specs/npm/npmrc_bad_registry_config/main.out, tests/specs/npm/npmrc_bad_token/.npmrc, tests/specs/npm/npmrc_bad_token/main.out, tests/specs/npm/npmrc_basic_auth/.npmrc, tests/specs/npm/npmrc_basic_auth/install.out, tests/specs/npm/npmrc_deno_json/.npmrc, tests/specs/npm/npmrc_deno_json/main.out, tests/specs/npm/npmrc_not_next_to_package_json/.npmrc, tests/specs/npm/npmrc_tarball_other_server/__test__.jsonc, tests/specs/npm/npmrc_tarball_other_server/fail/.npmrc, tests/specs/npm/npmrc_tarball_other_server/fail/main.js, tests/specs/npm/npmrc_tarball_other_server/fail/main.out, tests/specs/npm/npmrc_tarball_other_server/fail/package.json, tests/specs/npm/npmrc_tarball_other_server/success/.npmrc, tests/specs/npm/npmrc_tarball_other_server/success/main.js, tests/specs/npm/npmrc_tarball_other_server/success/main.out, tests/specs/npm/npmrc_tarball_other_server/success/package.json, tests/util/server/src/npm.rs
Commit date: 2024-06-05 21:24:52 +0000
Commit message: fix(npm): use configured auth for tarball urls instead of scope auth (#24111)

Deno was using the scope auth for the tarball urls, which is not always
correct. We are going to do a release immediately for this issue.
Diff: diff --git a/Cargo.lock b/Cargo.lock
index 10313dca90..a34f7af556 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1732,13 +1732,13 @@ dependencies = [
  "x509-parser",
 ]
 
 [[package]]
 name = "deno_npm"
-version = "0.21.1"
+version = "0.21.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0971210ea837a8153c2e1bec7d65aabf372f436845e576db95c596c0f4b1209d"
+checksum = "5c7ae566a3cba78bf05751c20708f28385fe339b1d07bd8daff16316317d4228"
 dependencies = [
  "anyhow",
  "async-trait",
  "deno_lockfile",
  "deno_semver",
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index 38cb8402bb..5f029c714f 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -70,11 +70,11 @@ deno_core = { workspace = true, features = ["include_js_files_for_snapshotting"]
 deno_doc = { version = "=0.139.0", features = ["html", "syntect"] }
 deno_emit = "=0.42.0"
 deno_graph = { version = "=0.78.0", features = ["tokio_executor"] }
 deno_lint = { version = "=0.60.0", features = ["docs"] }
 deno_lockfile.workspace = true
-deno_npm = "=0.21.1"
+deno_npm = "=0.21.2"
 deno_runtime = { workspace = true, features = ["include_js_files_for_snapshotting"] }
 deno_semver = "=0.5.4"
 deno_task_shell = "=0.16.1"
 deno_terminal.workspace = true
 eszip = "=0.71.0"
diff --git a/cli/args/mod.rs b/cli/args/mod.rs
index 6b2d361298..d5e37acc8b 100644
--- a/cli/args/mod.rs
+++ b/cli/args/mod.rs
@@ -617,10 +617,11 @@ pub fn create_default_npmrc() -> Arc<ResolvedNpmRc> {
     default_config: deno_npm::npm_rc::RegistryConfigWithUrl {
       registry_url: npm_registry_url().clone(),
       config: Default::default(),
     },
     scopes: Default::default(),
+    registry_configs: Default::default(),
   })
 }
 
 struct CliRootCertStoreProvider {
   cell: OnceCell<RootCertStore>,
diff --git a/cli/http_util.rs b/cli/http_util.rs
index 5042f5078c..7fcce616b4 100644
--- a/cli/http_util.rs
+++ b/cli/http_util.rs
@@ -5,11 +5,10 @@ use crate::util::progress_bar::UpdateGuard;
 use crate::version::get_user_agent;
 
 use cache_control::Cachability;
 use cache_control::CacheControl;
 use chrono::DateTime;
-use deno_core::anyhow::bail;
 use deno_core::error::custom_error;
 use deno_core::error::generic_error;
 use deno_core::error::AnyError;
 use deno_core::futures::StreamExt;
 use deno_core::parking_lot::Mutex;
@@ -28,10 +27,11 @@ use deno_runtime::deno_tls::RootCertStoreProvider;
 use std::collections::HashMap;
 use std::sync::Arc;
 use std::thread::ThreadId;
 use std::time::Duration;
 use std::time::SystemTime;
+use thiserror::Error;
 
 // TODO(ry) HTTP headers are not unique key, value pairs. There may be more than
 // one header line with the same key. This should be changed to something like
 // Vec<(String, String)>
 pub type HeadersMap = HashMap<String, String>;
@@ -258,10 +258,31 @@ impl HttpClientProvider {
       }
     }
   }
 }
 
+#[derive(Debug, Error)]
+#[error("Bad response: {:?}{}", .status_code, .response_text.as_ref().map(|s| format!("\n\n{}", s)).unwrap_or_else(String::new))]
+pub struct BadResponseError {
+  pub status_code: StatusCode,
+  pub response_text: Option<String>,
+}
+
+#[derive(Debug, Error)]
+pub enum DownloadError {
+  #[error(transparent)]
+  Reqwest(#[from] reqwest::Error),
+  #[error(transparent)]
+  ToStr(#[from] reqwest::header::ToStrError),
+  #[error("Redirection from '{}' did not provide location header", .request_url)]
+  NoRedirectHeader { request_url: Url },
+  #[error("Too many redirects.")]
+  TooManyRedirects,
+  #[error(transparent)]
+  BadResponse(#[from] BadResponseError),
+}
+
 #[derive(Debug)]
 pub struct HttpClient {
   #[allow(clippy::disallowed_types)] // reqwest::Client allowed here
   client: reqwest::Client,
   // don't allow sending this across threads because then
@@ -407,11 +428,11 @@ impl HttpClient {
   pub async fn download_with_progress(
     &self,
     url: impl reqwest::IntoUrl,
     maybe_header: Option<(HeaderName, HeaderValue)>,
     progress_guard: &UpdateGuard,
-  ) -> Result<Option<Vec<u8>>, AnyError> {
+  ) -> Result<Option<Vec<u8>>, DownloadError> {
     self
       .download_inner(url, maybe_header, Some(progress_guard))
       .await
   }
 
@@ -427,38 +448,37 @@ impl HttpClient {
   async fn download_inner(
     &self,
     url: impl reqwest::IntoUrl,
     maybe_header: Option<(HeaderName, HeaderValue)>,
     progress_guard: Option<&UpdateGuard>,
-  ) -> Result<Option<Vec<u8>>, AnyError> {
+  ) -> Result<Option<Vec<u8>>, DownloadError> {
     let response = self.get_redirected_response(url, maybe_header).await?;
 
     if response.status() == 404 {
       return Ok(None);
     } else if !response.status().is_success() {
       let status = response.status();
       let maybe_response_text = response.text().await.ok();
-      bail!(
-        "Bad response: {:?}{}",
-        status,
-        match maybe_response_text {
-          Some(text) => format!("\n\n{text}"),
-          None => String::new(),
-        }
-      );
+      return Err(DownloadError::BadResponse(BadResponseError {
+        status_code: status,
+        response_text: maybe_response_text
+          .map(|s| s.trim().to_string())
+          .filter(|s| !s.is_empty()),
+      }));
     }
 
     get_response_body_with_progress(response, progress_guard)
       .await
       .map(Some)
+      .map_err(Into::into)
   }
 
   async fn get_redirected_response(
     &self,
     url: impl reqwest::IntoUrl,
     mut maybe_header: Option<(HeaderName, HeaderValue)>,
-  ) -> Result<reqwest::Response, AnyError> {
+  ) -> Result<reqwest::Response, DownloadError> {
     let mut url = url.into_url()?;
     let mut builder = self.get(url.clone());
     if let Some((header_name, header_value)) = maybe_header.as_ref() {
       builder = builder.header(header_name, header_value);
     }
@@ -484,21 +504,21 @@ impl HttpClient {
           url = new_url;
         } else {
           return Ok(new_response);
         }
       }
-      Err(custom_error("Http", "Too many redirects."))
+      Err(DownloadError::TooManyRedirects)
     } else {
       Ok(response)
     }
   }
 }
 
 async fn get_response_body_with_progress(
   response: reqwest::Response,
   progress_guard: Option<&UpdateGuard>,
-) -> Result<Vec<u8>, AnyError> {
+) -> Result<Vec<u8>, reqwest::Error> {
   if let Some(progress_guard) = progress_guard {
     if let Some(total_size) = response.content_length() {
       progress_guard.set_total_size(total_size);
       let mut current_size = 0;
       let mut data = Vec::with_capacity(total_size as usize);
@@ -544,21 +564,21 @@ fn resolve_url_from_location(base_url: &Url, location: &str) -> Url {
 }
 
 fn resolve_redirect_from_response(
   request_url: &Url,
   response: &reqwest::Response,
-) -> Result<Url, AnyError> {
+) -> Result<Url, DownloadError> {
   debug_assert!(response.status().is_redirection());
   if let Some(location) = response.headers().get(LOCATION) {
     let location_string = location.to_str()?;
     log::debug!("Redirecting to {:?}...", &location_string);
     let new_url = resolve_url_from_location(request_url, location_string);
     Ok(new_url)
   } else {
-    Err(generic_error(format!(
-      "Redirection from '{request_url}' did not provide location header"
-    )))
+    Err(DownloadError::NoRedirectHeader {
+      request_url: request_url.clone(),
+    })
   }
 }
 
 #[allow(clippy::print_stdout)]
 #[allow(clippy::print_stderr)]
diff --git a/cli/npm/managed/cache/tarball.rs b/cli/npm/managed/cache/tarball.rs
index 042c3cbb2b..46186b87c7 100644
--- a/cli/npm/managed/cache/tarball.rs
+++ b/cli/npm/managed/cache/tarball.rs
@@ -13,12 +13,15 @@ use deno_core::futures::FutureExt;
 use deno_core::parking_lot::Mutex;
 use deno_npm::npm_rc::ResolvedNpmRc;
 use deno_npm::registry::NpmPackageVersionDistInfo;
 use deno_runtime::deno_fs::FileSystem;
 use deno_semver::package::PackageNv;
+use reqwest::StatusCode;
+use reqwest::Url;
 
 use crate::args::CacheSetting;
+use crate::http_util::DownloadError;
 use crate::http_util::HttpClientProvider;
 use crate::npm::common::maybe_auth_header_for_npm_registry;
 use crate::util::progress_bar::ProgressBar;
 use crate::util::sync::MultiRuntimeAsyncValueCreator;
 
@@ -136,12 +139,10 @@ impl TarballCache {
     dist: NpmPackageVersionDistInfo,
   ) -> LoadFuture {
     let tarball_cache = self.clone();
     async move {
       let registry_url = tarball_cache.npmrc.get_registry_url(&package_nv.name);
-      let registry_config =
-        tarball_cache.npmrc.get_registry_config(&package_nv.name).clone();
       let package_folder =
         tarball_cache.cache.package_folder_for_nv_and_url(&package_nv, registry_url);
       let should_use_cache = tarball_cache.cache.should_use_cache_for_package(&package_nv);
       let package_folder_exists = tarball_cache.fs.exists_sync(&package_folder);
       if should_use_cache && package_folder_exists {
@@ -159,18 +160,44 @@ impl TarballCache {
 
       if dist.tarball.is_empty() {
         bail!("Tarball URL was empty.");
       }
 
-      let maybe_auth_header =
-        maybe_auth_header_for_npm_registry(&registry_config);
+      // IMPORTANT: npm registries may specify tarball URLs at different URLS than the
+      // registry, so we MUST get the auth for the tarball URL and not the registry URL.
+      let tarball_uri = Url::parse(&dist.tarball)?;
+      let maybe_registry_config =
+        tarball_cache.npmrc.tarball_config(&tarball_uri);
+      let maybe_auth_header = maybe_registry_config.and_then(|c| maybe_auth_header_for_npm_registry(c));
 
       let guard = tarball_cache.progress_bar.update(&dist.tarball);
-      let maybe_bytes = tarball_cache.http_client_provider
+      let result = tarball_cache.http_client_provider
         .get_or_create()?
-        .download_with_progress(&dist.tarball, maybe_auth_header, &guard)
-        .await?;
+        .download_with_progress(tarball_uri, maybe_auth_header, &guard)
+        .await;
+      let maybe_bytes = match result {
+        Ok(maybe_bytes) => maybe_bytes,
+        Err(DownloadError::BadResponse(err)) => {
+          if err.status_code == StatusCode::UNAUTHORIZED
+            && maybe_registry_config.is_none()
+            && tarball_cache.npmrc.get_registry_config(&package_nv.name).auth_token.is_some()
+          {
+            bail!(
+              concat!(
+                "No auth for tarball URI, but present for scoped registry.\n\n",
+                "Tarball URI: {}\n",
+                "Scope URI: {}\n\n",
+                "More info here: https://github.com/npm/cli/wiki/%22No-auth-for-URI,-but-auth-present-for-scoped-registry%22"
+              ),
+              dist.tarball,
+              registry_url,
+            )
+          }
+          return Err(err.into())
+        },
+        Err(err) => return Err(err.into()),
+      };
       match maybe_bytes {
         Some(bytes) => {
           let extraction_mode = if should_use_cache || !package_folder_exists {
             TarballExtractionMode::SiblingTempDir
           } else {
diff --git a/tests/integration/lsp_tests.rs b/tests/integration/lsp_tests.rs
index a46c2ab251..581d436bb9 100644
--- a/tests/integration/lsp_tests.rs
+++ b/tests/integration/lsp_tests.rs
@@ -8894,12 +8894,12 @@ fn lsp_npmrc() {
     .to_string(),
   );
   temp_dir.write(
     temp_dir.path().join(".npmrc"),
     "\
-@denotest:registry=http://127.0.0.1:4261/
-//127.0.0.1:4261/:_authToken=private-reg-token
+@denotest:registry=http://localhost:4261/
+//localhost:4261/:_authToken=private-reg-token
 ",
   );
   let file = source_file(
     temp_dir.path().join("main.ts"),
     r#"
diff --git a/tests/registry/npm-private/@denotest/tarballs-privateserver2/1.0.0/index.js b/tests/registry/npm-private/@denotest/tarballs-privateserver2/1.0.0/index.js
new file mode 100644
index 0000000000..950e55cefc
--- /dev/null
+++ b/tests/registry/npm-private/@denotest/tarballs-privateserver2/1.0.0/index.js
@@ -0,0 +1 @@
+module.exports = () => 'hi_private1';
diff --git a/tests/registry/npm-private/@denotest/tarballs-privateserver2/1.0.0/package.json b/tests/registry/npm-private/@denotest/tarballs-privateserver2/1.0.0/package.json
new file mode 100644
index 0000000000..10554e2b92
--- /dev/null
+++ b/tests/registry/npm-private/@denotest/tarballs-privateserver2/1.0.0/package.json
@@ -0,0 +1,4 @@
+{
+  "name": "@denotest/tarballs-privateserver2",
+  "version": "1.0.0"
+}
\ No newline at end of file
diff --git a/tests/registry/npm-private2/@denotest/tarballs-privateserver2/1.0.0/index.js b/tests/registry/npm-private2/@denotest/tarballs-privateserver2/1.0.0/index.js
new file mode 100644
index 0000000000..3618a6690b
--- /dev/null
+++ b/tests/registry/npm-private2/@denotest/tarballs-privateserver2/1.0.0/index.js
@@ -0,0 +1 @@
+module.exports = () => 'hi_private2';
diff --git a/tests/registry/npm-private2/@denotest/tarballs-privateserver2/1.0.0/package.json b/tests/registry/npm-private2/@denotest/tarballs-privateserver2/1.0.0/package.json
new file mode 100644
index 0000000000..10554e2b92
--- /dev/null
+++ b/tests/registry/npm-private2/@denotest/tarballs-privateserver2/1.0.0/package.json
@@ -0,0 +1,4 @@
+{
+  "name": "@denotest/tarballs-privateserver2",
+  "version": "1.0.0"
+}
\ No newline at end of file
diff --git a/tests/registry/npm/@denotest/tarballs-privateserver2/1.0.0/index.js b/tests/registry/npm/@denotest/tarballs-privateserver2/1.0.0/index.js
new file mode 100644
index 0000000000..73af5036a4
--- /dev/null
+++ b/tests/registry/npm/@denotest/tarballs-privateserver2/1.0.0/index.js
@@ -0,0 +1,2 @@
+// this is a special package that the test server serves tarballs from the second private registry server
+module.exports = () => 'hi';
diff --git a/tests/registry/npm/@denotest/tarballs-privateserver2/1.0.0/package.json b/tests/registry/npm/@denotest/tarballs-privateserver2/1.0.0/package.json
new file mode 100644
index 0000000000..10554e2b92
--- /dev/null
+++ b/tests/registry/npm/@denotest/tarballs-privateserver2/1.0.0/package.json
@@ -0,0 +1,4 @@
+{
+  "name": "@denotest/tarballs-privateserver2",
+  "version": "1.0.0"
+}
\ No newline at end of file
diff --git a/tests/specs/compile/npmrc/.npmrc b/tests/specs/compile/npmrc/.npmrc
index 88c811ad6d..13552ad61f 100644
--- a/tests/specs/compile/npmrc/.npmrc
+++ b/tests/specs/compile/npmrc/.npmrc
@@ -1,4 +1,4 @@
-@denotest:registry=http://127.0.0.1:4261/
-//127.0.0.1:4261/:_authToken=private-reg-token
-@denotest2:registry=http://127.0.0.1:4262/
-//127.0.0.1:4262/:_authToken=private-reg-token2
+@denotest:registry=http://localhost:4261/
+//localhost:4261/:_authToken=private-reg-token
+@denotest2:registry=http://localhost:4262/
+//localhost:4262/:_authToken=private-reg-token2
diff --git a/tests/specs/compile/npmrc/install.out b/tests/specs/compile/npmrc/install.out
index 7484405db9..5c2ff35629 100644
--- a/tests/specs/compile/npmrc/install.out
+++ b/tests/specs/compile/npmrc/install.out
@@ -1,9 +1,9 @@
 ⚠️ `deno install` behavior will change in Deno 2. To preserve the current behavior use the `-g` or `--global` flag.
 [UNORDERED_START]
-Download http://127.0.0.1:4261/@denotest/basic
-Download http://127.0.0.1:4262/@denotest2/basic
+Download http://localhost:4261/@denotest/basic
+Download http://localhost:4262/@denotest2/basic
 Download http://localhost:4261/@denotest/basic/1.0.0.tgz
 Download http://localhost:4262/@denotest2/basic/1.0.0.tgz
 Initialize @denotest2/basic@1.0.0
 Initialize @denotest/basic@1.0.0
 [UNORDERED_END]
diff --git a/tests/specs/npm/npmrc/.npmrc b/tests/specs/npm/npmrc/.npmrc
index 88c811ad6d..13552ad61f 100644
--- a/tests/specs/npm/npmrc/.npmrc
+++ b/tests/specs/npm/npmrc/.npmrc
@@ -1,4 +1,4 @@
-@denotest:registry=http://127.0.0.1:4261/
-//127.0.0.1:4261/:_authToken=private-reg-token
-@denotest2:registry=http://127.0.0.1:4262/
-//127.0.0.1:4262/:_authToken=private-reg-token2
+@denotest:registry=http://localhost:4261/
+//localhost:4261/:_authToken=private-reg-token
+@denotest2:registry=http://localhost:4262/
+//localhost:4262/:_authToken=private-reg-token2
diff --git a/tests/specs/npm/npmrc/install.out b/tests/specs/npm/npmrc/install.out
index 7484405db9..5c2ff35629 100644
--- a/tests/specs/npm/npmrc/install.out
+++ b/tests/specs/npm/npmrc/install.out
@@ -1,9 +1,9 @@
 ⚠️ `deno install` behavior will change in Deno 2. To preserve the current behavior use the `-g` or `--global` flag.
 [UNORDERED_START]
-Download http://127.0.0.1:4261/@denotest/basic
-Download http://127.0.0.1:4262/@denotest2/basic
+Download http://localhost:4261/@denotest/basic
+Download http://localhost:4262/@denotest2/basic
 Download http://localhost:4261/@denotest/basic/1.0.0.tgz
 Download http://localhost:4262/@denotest2/basic/1.0.0.tgz
 Initialize @denotest2/basic@1.0.0
 Initialize @denotest/basic@1.0.0
 [UNORDERED_END]
diff --git a/tests/specs/npm/npmrc_bad_registry_config/.npmrc b/tests/specs/npm/npmrc_bad_registry_config/.npmrc
index 709720a452..3897db878c 100644
--- a/tests/specs/npm/npmrc_bad_registry_config/.npmrc
+++ b/tests/specs/npm/npmrc_bad_registry_config/.npmrc
@@ -1,5 +1,5 @@
-@denotest:registry=http://127.0.0.1:4261/
+@denotest:registry=http://localhost:4261/
 ; This configuration is wrong - the registry URL must
 ; be exactly the same as registry configured for the scope,
 ; not root url + scope name.
-//127.0.0.1:4261/denotest/:_authToken=invalid-token
+//localhost:4261/denotest/:_authToken=invalid-token
diff --git a/tests/specs/npm/npmrc_bad_registry_config/main.out b/tests/specs/npm/npmrc_bad_registry_config/main.out
index ceee1fed42..17619e5cea 100644
--- a/tests/specs/npm/npmrc_bad_registry_config/main.out
+++ b/tests/specs/npm/npmrc_bad_registry_config/main.out
@@ -1,4 +1,4 @@
 ⚠️ `deno install` behavior will change in Deno 2. To preserve the current behavior use the `-g` or `--global` flag.
-Download http://127.0.0.1:4261/@denotest/basic
-error: Error getting response at http://127.0.0.1:4261/@denotest/basic for package "@denotest/basic": Bad response: 401
+Download http://localhost:4261/@denotest/basic
+error: Error getting response at http://localhost:4261/@denotest/basic for package "@denotest/basic": Bad response: 401
 [WILDCARD]
\ No newline at end of file
diff --git a/tests/specs/npm/npmrc_bad_token/.npmrc b/tests/specs/npm/npmrc_bad_token/.npmrc
index 6ead678f4d..04f7c31096 100644
--- a/tests/specs/npm/npmrc_bad_token/.npmrc
+++ b/tests/specs/npm/npmrc_bad_token/.npmrc
@@ -1,2 +1,2 @@
-@denotest:registry=http://127.0.0.1:4261/
-//127.0.0.1:4261/:_authToken=invalid-token
+@denotest:registry=http://localhost:4261/
+//localhost:4261/:_authToken=invalid-token
diff --git a/tests/specs/npm/npmrc_bad_token/main.out b/tests/specs/npm/npmrc_bad_token/main.out
index ceee1fed42..17619e5cea 100644
--- a/tests/specs/npm/npmrc_bad_token/main.out
+++ b/tests/specs/npm/npmrc_bad_token/main.out
@@ -1,4 +1,4 @@
 ⚠️ `deno install` behavior will change in Deno 2. To preserve the current behavior use the `-g` or `--global` flag.
-Download http://127.0.0.1:4261/@denotest/basic
-error: Error getting response at http://127.0.0.1:4261/@denotest/basic for package "@denotest/basic": Bad response: 401
+Download http://localhost:4261/@denotest/basic
+error: Error getting response at http://localhost:4261/@denotest/basic for package "@denotest/basic": Bad response: 401
 [WILDCARD]
\ No newline at end of file
diff --git a/tests/specs/npm/npmrc_basic_auth/.npmrc b/tests/specs/npm/npmrc_basic_auth/.npmrc
index c5548f6f69..71177b979c 100644
--- a/tests/specs/npm/npmrc_basic_auth/.npmrc
+++ b/tests/specs/npm/npmrc_basic_auth/.npmrc
@@ -1,4 +1,4 @@
-@denotest:registry=http://127.0.0.1:4261/
-//127.0.0.1:4261/:_auth=ZGVubzpsYW5k
-@denotest2:registry=http://127.0.0.1:4262/
-//127.0.0.1:4262/:_auth=ZGVubzpsYW5kMg==
+@denotest:registry=http://localhost:4261/
+//localhost:4261/:_auth=ZGVubzpsYW5k
+@denotest2:registry=http://localhost:4262/
+//localhost:4262/:_auth=ZGVubzpsYW5kMg==
diff --git a/tests/specs/npm/npmrc_basic_auth/install.out b/tests/specs/npm/npmrc_basic_auth/install.out
index 7484405db9..5c2ff35629 100644
--- a/tests/specs/npm/npmrc_basic_auth/install.out
+++ b/tests/specs/npm/npmrc_basic_auth/install.out
@@ -1,9 +1,9 @@
 ⚠️ `deno install` behavior will change in Deno 2. To preserve the current behavior use the `-g` or `--global` flag.
 [UNORDERED_START]
-Download http://127.0.0.1:4261/@denotest/basic
-Download http://127.0.0.1:4262/@denotest2/basic
+Download http://localhost:4261/@denotest/basic
+Download http://localhost:4262/@denotest2/basic
 Download http://localhost:4261/@denotest/basic/1.0.0.tgz
 Download http://localhost:4262/@denotest2/basic/1.0.0.tgz
 Initialize @denotest2/basic@1.0.0
 Initialize @denotest/basic@1.0.0
 [UNORDERED_END]
diff --git a/tests/specs/npm/npmrc_deno_json/.npmrc b/tests/specs/npm/npmrc_deno_json/.npmrc
index cea5a0fad8..de3704b924 100644
--- a/tests/specs/npm/npmrc_deno_json/.npmrc
+++ b/tests/specs/npm/npmrc_deno_json/.npmrc
@@ -1,2 +1,2 @@
-@denotest:registry=http://127.0.0.1:4261/
-//127.0.0.1:4261/:_authToken=private-reg-token
+@denotest:registry=http://localhost:4261/
+//localhost:4261/:_authToken=private-reg-token
diff --git a/tests/specs/npm/npmrc_deno_json/main.out b/tests/specs/npm/npmrc_deno_json/main.out
index 6a1e47669c..62750088bd 100644
--- a/tests/specs/npm/npmrc_deno_json/main.out
+++ b/tests/specs/npm/npmrc_deno_json/main.out
@@ -1,4 +1,4 @@
-Download http://127.0.0.1:4261/@denotest/basic
+Download http://localhost:4261/@denotest/basic
 Download http://localhost:4261/@denotest/basic/1.0.0.tgz
 0
 42
diff --git a/tests/specs/npm/npmrc_not_next_to_package_json/.npmrc b/tests/specs/npm/npmrc_not_next_to_package_json/.npmrc
index cea5a0fad8..de3704b924 100644
--- a/tests/specs/npm/npmrc_not_next_to_package_json/.npmrc
+++ b/tests/specs/npm/npmrc_not_next_to_package_json/.npmrc
@@ -1,2 +1,2 @@
-@denotest:registry=http://127.0.0.1:4261/
-//127.0.0.1:4261/:_authToken=private-reg-token
+@denotest:registry=http://localhost:4261/
+//localhost:4261/:_authToken=private-reg-token
diff --git a/tests/specs/npm/npmrc_tarball_other_server/__test__.jsonc b/tests/specs/npm/npmrc_tarball_other_server/__test__.jsonc
new file mode 100644
index 0000000000..dfb311c1ec
--- /dev/null
+++ b/tests/specs/npm/npmrc_tarball_other_server/__test__.jsonc
@@ -0,0 +1,17 @@
+{
+  "tempDir": true,
+  "tests": {
+    "auth_success": {
+      "cwd": "success",
+      "args": "run --node-modules-dir -A main.js",
+      "output": "success/main.out",
+      "exitCode": 1
+    },
+    "auth_fail": {
+      "cwd": "fail",
+      "args": "run --node-modules-dir -A main.js",
+      "output": "fail/main.out",
+      "exitCode": 1
+    }
+  }
+}
diff --git a/tests/specs/npm/npmrc_tarball_other_server/fail/.npmrc b/tests/specs/npm/npmrc_tarball_other_server/fail/.npmrc
new file mode 100644
index 0000000000..de3704b924
--- /dev/null
+++ b/tests/specs/npm/npmrc_tarball_other_server/fail/.npmrc
@@ -0,0 +1,2 @@
+@denotest:registry=http://localhost:4261/
+//localhost:4261/:_authToken=private-reg-token
diff --git a/tests/specs/npm/npmrc_tarball_other_server/fail/main.js b/tests/specs/npm/npmrc_tarball_other_server/fail/main.js
new file mode 100644
index 0000000000..176874aa54
--- /dev/null
+++ b/tests/specs/npm/npmrc_tarball_other_server/fail/main.js
@@ -0,0 +1,3 @@
+import getValue from "@denotest/tarballs-privateserver2";
+
+console.log(getValue());
diff --git a/tests/specs/npm/npmrc_tarball_other_server/fail/main.out b/tests/specs/npm/npmrc_tarball_other_server/fail/main.out
new file mode 100644
index 0000000000..08a84a477c
--- /dev/null
+++ b/tests/specs/npm/npmrc_tarball_other_server/fail/main.out
@@ -0,0 +1,11 @@
+Download http://localhost:4261/@denotest/tarballs-privateserver2
+Download http://localhost:4262/@denotest/tarballs-privateserver2/1.0.0.tgz
+error: Failed caching npm package '@denotest/tarballs-privateserver2@1.0.0'.
+
+Caused by:
+    No auth for tarball URI, but present for scoped registry.
+    
+    Tarball URI: http://localhost:4262/@denotest/tarballs-privateserver2/1.0.0.tgz
+    Scope URI: http://localhost:4261/
+    
+    More info here: https://github.com/npm/cli/wiki/%22No-auth-for-URI,-but-auth-present-for-scoped-registry%22
diff --git a/tests/specs/npm/npmrc_tarball_other_server/fail/package.json b/tests/specs/npm/npmrc_tarball_other_server/fail/package.json
new file mode 100644
index 0000000000..2effd6bda5
--- /dev/null
+++ b/tests/specs/npm/npmrc_tarball_other_server/fail/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "npmrc_test",
+  "version": "0.0.1",
+  "dependencies": {
+    "@denotest/tarballs-privateserver2": "1.0.0"
+  }
+}
diff --git a/tests/specs/npm/npmrc_tarball_other_server/success/.npmrc b/tests/specs/npm/npmrc_tarball_other_server/success/.npmrc
new file mode 100644
index 0000000000..cc2dde26fd
--- /dev/null
+++ b/tests/specs/npm/npmrc_tarball_other_server/success/.npmrc
@@ -0,0 +1,3 @@
+@denotest:registry=http://localhost:4261/
+//localhost:4261/:_authToken=private-reg-token
+//localhost:4262/:_authToken=private-reg-token2
diff --git a/tests/specs/npm/npmrc_tarball_other_server/success/main.js b/tests/specs/npm/npmrc_tarball_other_server/success/main.js
new file mode 100644
index 0000000000..176874aa54
--- /dev/null
+++ b/tests/specs/npm/npmrc_tarball_other_server/success/main.js
@@ -0,0 +1,3 @@
+import getValue from "@denotest/tarballs-privateserver2";
+
+console.log(getValue());
diff --git a/tests/specs/npm/npmrc_tarball_other_server/success/main.out b/tests/specs/npm/npmrc_tarball_other_server/success/main.out
new file mode 100644
index 0000000000..d75f26e336
--- /dev/null
+++ b/tests/specs/npm/npmrc_tarball_other_server/success/main.out
@@ -0,0 +1,10 @@
+Download http://localhost:4261/@denotest/tarballs-privateserver2
+Download http://localhost:4262/@denotest/tarballs-privateserver2/1.0.0.tgz
+[# This fails on a checksum issue, because the test server isn't smart enough]
+[# to serve proper checksums for a package at another registry. That's fine]
+[# though because this shows us that we're making it to this step instead of]
+[# failing sooner on an auth issue.]
+error: Failed caching npm package '@denotest/tarballs-privateserver2@1.0.0'.
+
+Caused by:
+    Tarball checksum did not match [WILDCARD]
diff --git a/tests/specs/npm/npmrc_tarball_other_server/success/package.json b/tests/specs/npm/npmrc_tarball_other_server/success/package.json
new file mode 100644
index 0000000000..2effd6bda5
--- /dev/null
+++ b/tests/specs/npm/npmrc_tarball_other_server/success/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "npmrc_test",
+  "version": "0.0.1",
+  "dependencies": {
+    "@denotest/tarballs-privateserver2": "1.0.0"
+  }
+}
diff --git a/tests/util/server/src/npm.rs b/tests/util/server/src/npm.rs
index 363a45d7e5..66b7bddcd7 100644
--- a/tests/util/server/src/npm.rs
+++ b/tests/util/server/src/npm.rs
@@ -163,10 +163,16 @@ impl TestNpmRegistry {
 fn get_npm_package(
   registry_hostname: &str,
   local_path: &str,
   package_name: &str,
 ) -> Result<Option<CustomNpmPackage>> {
+  let registry_hostname = if package_name == "@denotest/tarballs-privateserver2"
+  {
+    "http://localhost:4262"
+  } else {
+    registry_hostname
+  };
   let package_folder = tests_path()
     .join("registry")
     .join(local_path)
     .join(package_name);
   if !package_folder.exists() {

Vulnerable code: version = "0.21.1"
checksum = "0971210ea837a8153c2e1bec7d65aabf372f436845e576db95c596c0f4b1209d"
deno_npm = "=0.21.1"
use deno_core::anyhow::bail;
  ) -> Result<Option<Vec<u8>>, AnyError> {
  ) -> Result<Option<Vec<u8>>, AnyError> {
      bail!(
        "Bad response: {:?}{}",
        status,
        match maybe_response_text {
          Some(text) => format!("\n\n{text}"),
          None => String::new(),
        }
      );
  ) -> Result<reqwest::Response, AnyError> {
      Err(custom_error("Http", "Too many redirects."))
) -> Result<Vec<u8>, AnyError> {
) -> Result<Url, AnyError> {
    Err(generic_error(format!(
      "Redirection from '{request_url}' did not provide location header"
    )))
      let registry_config =
        tarball_cache.npmrc.get_registry_config(&package_nv.name).clone();
      let maybe_auth_header =
        maybe_auth_header_for_npm_registry(&registry_config);
      let maybe_bytes = tarball_cache.http_client_provider
        .download_with_progress(&dist.tarball, maybe_auth_header, &guard)
        .await?;
@denotest:registry=http://127.0.0.1:4261/
//127.0.0.1:4261/:_authToken=private-reg-token
@denotest:registry=http://127.0.0.1:4261/
//127.0.0.1:4261/:_authToken=private-reg-token
@denotest2:registry=http://127.0.0.1:4262/
//127.0.0.1:4262/:_authToken=private-reg-token2
Download http://127.0.0.1:4261/@denotest/basic
Download http://127.0.0.1:4262/@denotest2/basic
@denotest:registry=http://127.0.0.1:4261/
//127.0.0.1:4261/:_authToken=private-reg-token
@denotest2:registry=http://127.0.0.1:4262/
//127.0.0.1:4262/:_authToken=private-reg-token2
Download http://127.0.0.1:4261/@denotest/basic
Download http://127.0.0.1:4262/@denotest2/basic
@denotest:registry=http://127.0.0.1:4261/
//127.0.0.1:4261/denotest/:_authToken=invalid-token
Download http://127.0.0.1:4261/@denotest/basic
error: Error getting response at http://127.0.0.1:4261/@denotest/basic for package "@denotest/basic": Bad response: 401
@denotest:registry=http://127.0.0.1:4261/
//127.0.0.1:4261/:_authToken=invalid-token
Download http://127.0.0.1:4261/@denotest/basic
error: Error getting response at http://127.0.0.1:4261/@denotest/basic for package "@denotest/basic": Bad response: 401
@denotest:registry=http://127.0.0.1:4261/
//127.0.0.1:4261/:_auth=ZGVubzpsYW5k
@denotest2:registry=http://127.0.0.1:4262/
//127.0.0.1:4262/:_auth=ZGVubzpsYW5kMg==
Download http://127.0.0.1:4261/@denotest/basic
Download http://127.0.0.1:4262/@denotest2/basic
@denotest:registry=http://127.0.0.1:4261/
//127.0.0.1:4261/:_authToken=private-reg-token
Download http://127.0.0.1:4261/@denotest/basic
@denotest:registry=http://127.0.0.1:4261/
//127.0.0.1:4261/:_authToken=private-reg-token
Fixed code: version = "0.21.2"
checksum = "5c7ae566a3cba78bf05751c20708f28385fe339b1d07bd8daff16316317d4228"
deno_npm = "=0.21.2"
    registry_configs: Default::default(),
use thiserror::Error;
#[derive(Debug, Error)]
#[error("Bad response: {:?}{}", .status_code, .response_text.as_ref().map(|s| format!("\n\n{}", s)).unwrap_or_else(String::new))]
pub struct BadResponseError {
  pub status_code: StatusCode,
  pub response_text: Option<String>,
}

#[derive(Debug, Error)]
pub enum DownloadError {
  #[error(transparent)]
  Reqwest(#[from] reqwest::Error),
  #[error(transparent)]
  ToStr(#[from] reqwest::header::ToStrError),
  #[error("Redirection from '{}' did not provide location header", .request_url)]
  NoRedirectHeader { request_url: Url },
  #[error("Too many redirects.")]
  TooManyRedirects,
  #[error(transparent)]
  BadResponse(#[from] BadResponseError),
}

  ) -> Result<Option<Vec<u8>>, DownloadError> {
  ) -> Result<Option<Vec<u8>>, DownloadError> {
      return Err(DownloadError::BadResponse(BadResponseError {
        status_code: status,
        response_text: maybe_response_text
          .map(|s| s.trim().to_string())
          .filter(|s| !s.is_empty()),
      }));
      .map_err(Into::into)
  ) -> Result<reqwest::Response, DownloadError> {
      Err(DownloadError::TooManyRedirects)
) -> Result<Vec<u8>, reqwest::Error> {
) -> Result<Url, DownloadError> {
    Err(DownloadError::NoRedirectHeader {
      request_url: request_url.clone(),
    })
use reqwest::StatusCode;
use reqwest::Url;
use crate::http_util::DownloadError;
      // IMPORTANT: npm registries may specify tarball URLs at different URLS than the
      // registry, so we MUST get the auth for the tarball URL and not the registry URL.
      let tarball_uri = Url::parse(&dist.tarball)?;
      let maybe_registry_config =
        tarball_cache.npmrc.tarball_config(&tarball_uri);
      let maybe_auth_header = maybe_registry_config.and_then(|c| maybe_auth_header_for_npm_registry(c));
      let result = tarball_cache.http_client_provider
        .download_with_progress(tarball_uri, maybe_auth_header, &guard)
        .await;
      let maybe_bytes = match result {
        Ok(maybe_bytes) => maybe_bytes,
        Err(DownloadError::BadResponse(err)) => {
          if err.status_code == StatusCode::UNAUTHORIZED
            && maybe_registry_config.is_none()
            && tarball_cache.npmrc.get_registry_config(&package_nv.name).auth_token.is_some()
          {
            bail!(
              concat!(
                "No auth for tarball URI, but present for scoped registry.\n\n",
                "Tarball URI: {}\n",
                "Scope URI: {}\n\n",
                "More info here: https://github.com/npm/cli/wiki/%22No-auth-for-URI,-but-auth-present-for-scoped-registry%22"
              ),
              dist.tarball,
              registry_url,
            )
          }
          return Err(err.into())
        },
        Err(err) => return Err(err.into()),
      };
@denotest:registry=http://localhost:4261/
//localhost:4261/:_authToken=private-reg-token
module.exports = () => 'hi_private1';
{
  "name": "@denotest/tarballs-privateserver2",
  "version": "1.0.0"
}
module.exports = () => 'hi_private2';
{
  "name": "@denotest/tarballs-privateserver2",
  "version": "1.0.0"
}
// this is a special package that the test server serves tarballs from the second private registry server
module.exports = () => 'hi';
{
  "name": "@denotest/tarballs-privateserver2",
  "version": "1.0.0"
}
@denotest:registry=http://localhost:4261/
//localhost:4261/:_authToken=private-reg-token
@denotest2:registry=http://localhost:4262/
//localhost:4262/:_authToken=private-reg-token2
Download http://localhost:4261/@denotest/basic
Download http://localhost:4262/@denotest2/basic
@denotest:registry=http://localhost:4261/
//localhost:4261/:_authToken=private-reg-token
@denotest2:registry=http://localhost:4262/
//localhost:4262/:_authToken=private-reg-token2
Download http://localhost:4261/@denotest/basic
Download http://localhost:4262/@denotest2/basic
@denotest:registry=http://localhost:4261/
//localhost:4261/denotest/:_authToken=invalid-token
Download http://localhost:4261/@denotest/basic
error: Error getting response at http://localhost:4261/@denotest/basic for package "@denotest/basic": Bad response: 401
@denotest:registry=http://localhost:4261/
//localhost:4261/:_authToken=invalid-token
Download http://localhost:4261/@denotest/basic
error: Error getting response at http://localhost:4261/@denotest/basic for package "@denotest/basic": Bad response: 401
@denotest:registry=http://localhost:4261/
//localhost:4261/:_auth=ZGVubzpsYW5k
@denotest2:registry=http://localhost:4262/
//localhost:4262/:_auth=ZGVubzpsYW5kMg==
Download http://localhost:4261/@denotest/basic
Download http://localhost:4262/@denotest2/basic
@denotest:registry=http://localhost:4261/
//localhost:4261/:_authToken=private-reg-token
Download http://localhost:4261/@denotest/basic
@denotest:registry=http://localhost:4261/
//localhost:4261/:_authToken=private-reg-token
{
  "tempDir": true,
  "tests": {
    "auth_success": {
      "cwd": "success",
      "args": "run --node-modules-dir -A main.js",
      "output": "success/main.out",
      "exitCode": 1
    },
    "auth_fail": {
      "cwd": "fail",
      "args": "run --node-modules-dir -A main.js",
      "output": "fail/main.out",
      "exitCode": 1
    }
  }
}
@denotest:registry=http://localhost:4261/
//localhost:4261/:_authToken=private-reg-token
import getValue from "@denotest/tarballs-privateserver2";

console.log(getValue());
Download http://localhost:4261/@denotest/tarballs-privateserver2
Download http://localhost:4262/@denotest/tarballs-privateserver2/1.0.0.tgz
error: Failed caching npm package '@denotest/tarballs-privateserver2@1.0.0'.

Caused by:
    No auth for tarball URI, but present for scoped registry.
    
    Tarball URI: http://localhost:4262/@denotest/tarballs-privateserver2/1.0.0.tgz
    Scope URI: http://localhost:4261/
    
    More info here: https://github.com/npm/cli/wiki/%22No-auth-for-URI,-but-auth-present-for-scoped-registry%22
{
  "name": "npmrc_test",
  "version": "0.0.1",
  "dependencies": {
    "@denotest/tarballs-privateserver2": "1.0.0"
  }
}
@denotest:registry=http://localhost:4261/
//localhost:4261/:_authToken=private-reg-token
//localhost:4262/:_authToken=private-reg-token2
import getValue from "@denotest/tarballs-privateserver2";

console.log(getValue());
Download http://localhost:4261/@denotest/tarballs-privateserver2
Download http://localhost:4262/@denotest/tarballs-privateserver2/1.0.0.tgz
[# This fails on a checksum issue, because the test server isn't smart enough]
[# to serve proper checksums for a package at another registry. That's fine]
[# though because this shows us that we're making it to this step instead of]
[# failing sooner on an auth issue.]
error: Failed caching npm package '@denotest/tarballs-privateserver2@1.0.0'.

Caused by:
    Tarball checksum did not match [WILDCARD]
{
  "name": "npmrc_test",
  "version": "0.0.1",
  "dependencies": {
    "@denotest/tarballs-privateserver2": "1.0.0"
  }
}
  let registry_hostname = if package_name == "@denotest/tarballs-privateserver2"
  {
    "http://localhost:4262"
  } else {
    registry_hostname
  };