Repository: denoland/deno
Pull request: fix(npm): retain authorization header on http->https redirect

Fixes #29869.

## Summary

When an npm registry is configured with `http://` in `.npmrc` and the server
redirects to `https://`, the authorization header was being stripped. This
happened because the previous origin-based check (`url.origin() != new_url.origin()`)
treated scheme changes as cross-origin, since `url::Origin` includes the scheme.

This caused 401 errors for private registries like GitHub Packages when
configured with `http://` URLs.

## Changes

- Extract the redirect auth-stripping logic into a `should_strip_auth_on_redirect`
  helper function with clear semantics:
  - **Strip auth** when the host or port changes (true cross-origin redirect)
  - **Strip auth** on scheme downgrade (`https` -> `http`) to avoid leaking
    credentials over plaintext
  - **Retain auth** on same-host scheme upgrades (`http` -> `https`), which are
    safe redirects
- Add unit tests covering all cases (same-host upgrade, same origin, different
  host, different port, scheme downgrade)

Changed files:
filename: cli/http_util.rs
patch: @@ -351,8 +351,7 @@ impl HttpClient {
         let mut req = self.get(new_url.clone())?.build();
 
         let mut headers = headers.clone();
-        // SECURITY: Do NOT forward auth headers to a new origin
-        if new_url.origin() != url.origin() {
+        if should_strip_auth_on_redirect(&url, &new_url) {
           headers.remove(http::header::AUTHORIZATION);
         }
         *req.headers_mut() = headers;
@@ -378,6 +377,16 @@ impl HttpClient {
   }
 }
 
+/// Returns true if auth headers should be stripped when redirecting from
+/// `original` to `new_url`. Auth is stripped when the host or port changes,
+/// or on a scheme downgrade (https -> http) to avoid leaking credentials
+/// over plaintext. Same-host scheme upgrades (http -> https) are allowed.
+fn should_strip_auth_on_redirect(original: &Url, new_url: &Url) -> bool {
+  new_url.host() != original.host()
+    || new_url.port() != original.port()
+    || (original.scheme() == "https" && new_url.scheme() == "http")
+}
+
 pub async fn get_response_body_with_progress(
   response: http::Response<deno_fetch::ResBody>,
   progress_guard: Option<&UpdateGuard>,
@@ -728,4 +737,37 @@ mod test {
     assert_eq!(headers.get("etag"), None);
     assert_eq!(headers.get("x-typescript-types"), None);
   }
+
+  #[test]
+  fn test_should_strip_auth_on_redirect() {
+    // http -> https same host: safe upgrade, retain auth
+    assert!(!should_strip_auth_on_redirect(
+      &Url::parse("http://npm.pkg.github.com/package").unwrap(),
+      &Url::parse("https://npm.pkg.github.com/package").unwrap(),
+    ));
+
+    // same origin: retain auth
+    assert!(!should_strip_auth_on_redirect(
+      &Url::parse("https://registry.example.com/a").unwrap(),
+      &Url::parse("https://registry.example.com/b").unwrap(),
+    ));
+
+    // different host: strip auth
+    assert!(should_strip_auth_on_redirect(
+      &Url::parse("https://registry.example.com/package").unwrap(),
+      &Url::parse("https://other.example.com/package").unwrap(),
+    ));
+
+    // different port: strip auth
+    assert!(should_strip_auth_on_redirect(
+      &Url::parse("https://registry.example.com:8080/package").unwrap(),
+      &Url::parse("https://registry.example.com:9090/package").unwrap(),
+    ));
+
+    // https -> http same host: scheme downgrade, strip auth
+    assert!(should_strip_auth_on_redirect(
+      &Url::parse("https://npm.pkg.github.com/package").unwrap(),
+      &Url::parse("http://npm.pkg.github.com/package").unwrap(),
+    ));
+  }
 }