Cargo can be coerced to share credentials between registries
Description
Cargo between 1.68 and 1.96 incorrectly normalized the URLs of third-party registries using the sparse index protocol. If a hosting provider allowed multiple registries to be hosted with arbitrary names within the same domain, an attacker able to publish crates in a registry could obtain the credentials of others users of the same registry. The severity of the vulnerability is low, due to the extremely niche requirements needed to achieve the attack.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Cargo 1.68–1.96 incorrectly normalizes sparse registry URLs, allowing credential theft under niche multi-registry hosting conditions.
Vulnerability
Cargo versions 1.68 through 1.96 incorrectly apply URL normalization (stripping .git suffix) to sparse index registries, which was originally intended for git-based registries [1][2]. This causes Cargo to treat https://example.com/index and https://example.com/index.git as equivalent, even though sparse indexes treat them as distinct URLs [1]. Affected: Cargo 1.68 to 1.96 inclusive.
Exploitation
An attacker must have the ability to publish crates on a sparse registry hosted at a domain that also hosts another registry with a .git suffix (e.g., https://example.com/index.git) [1]. The attacker also needs to upload arbitrary files to that .git registry and configure it to require authentication for downloads, with a download URL pointing to a credential-harvesting server [1]. The attacker then publishes a crate foo on the legitimate registry that depends on a crate bar from the malicious .git registry, and tricks a victim into downloading foo [1]. When Cargo resolves dependencies, it mistakenly shares the victim's credentials for the legitimate registry with the malicious registry [1][2].
Impact
If successful, the attacker obtains the victim's Cargo authentication token for the legitimate registry [1][2]. This could allow the attacker to publish crates under the victim's identity or access private crates. The impact is limited by the extremely niche conditions required: the hosting provider must allow multiple registries with arbitrary names on the same domain, and the attacker must have publishing rights and file upload capabilities [1].
Mitigation
The fix is included in Cargo 1.96, scheduled for release on May 28, 2026 [2]. The pull request (#17031) addresses the issue by ensuring that .git suffix normalization is only applied to git-based registries, not sparse indexes [3]. Users should update to Cargo 1.96 or later. No workaround is available for earlier versions; users of affected versions should avoid using registries hosted on domains that allow arbitrary registry names [1].
AI Insight generated on May 25, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
103cb632e2473Fix CVE-2026-5222 and CVE-2026-5223 (#17031)
4 files changed · +57 −43
src/cargo/sources/git/source.rs+7 −0 modified@@ -501,6 +501,13 @@ mod test { assert_eq!(ident1, ident2); } + #[test] + fn test_canonicalize_idents_does_not_strip_dot_git_for_sparse() { + let ident1 = ident(&src("sparse+https://crates.io/fake-registry")); + let ident2 = ident(&src("sparse+https://crates.io/fake-registry.git")); + assert_ne!(ident1, ident2); + } + fn src(s: &str) -> SourceId { SourceId::for_git(&s.into_url().unwrap(), GitReference::DefaultBranch).unwrap() }
src/cargo/sources/registry/mod.rs+9 −1 modified@@ -198,7 +198,7 @@ use flate2::read::GzDecoder; use futures::FutureExt as _; use serde::Deserialize; use serde::Serialize; -use tar::Archive; +use tar::{Archive, EntryType}; use tracing::debug; use crate::core::dependency::Dependency; @@ -1019,6 +1019,14 @@ fn unpack( ) } + // Prevent unpacking symlinks and other unexpected entry types + match entry.header().entry_type() { + EntryType::Regular | EntryType::Directory => {} + t => anyhow::bail!( + "invalid tarball downloaded, contains an entry at {entry_path:?} with invalid type {t:?}", + ), + } + // Prevent unpacking the lockfile from the crate itself. if entry_path .file_name()
src/cargo/util/canonical_url.rs+24 −20 modified@@ -33,27 +33,31 @@ impl CanonicalUrl { url.path_segments_mut().unwrap().pop_if_empty(); } - // For GitHub URLs specifically, just lower-case everything. GitHub - // treats both the same, but they hash differently, and we're gonna be - // hashing them. This wants a more general solution, and also we're - // almost certainly not using the same case conversion rules that GitHub - // does. (See issue #84) - if url.host_str() == Some("github.com") { - url = format!("https{}", &url[url::Position::AfterScheme..]) - .parse() - .unwrap(); - let path = url.path().to_lowercase(); - url.set_path(&path); - } + // Perform further canonicalization specific to git registries, which + // do not contain a `+` specifier. + if !url.scheme().contains('+') { + // For GitHub URLs specifically, just lower-case everything. GitHub + // treats both the same, but they hash differently, and we're gonna be + // hashing them. This wants a more general solution, and also we're + // almost certainly not using the same case conversion rules that GitHub + // does. (See issue #84) + if url.host_str() == Some("github.com") { + url = format!("https{}", &url[url::Position::AfterScheme..]) + .parse() + .unwrap(); + let path = url.path().to_lowercase(); + url.set_path(&path); + } - // Repos can generally be accessed with or without `.git` extension. - let needs_chopping = url.path().ends_with(".git"); - if needs_chopping { - let last = { - let last = url.path_segments().unwrap().next_back().unwrap(); - last[..last.len() - 4].to_owned() - }; - url.path_segments_mut().unwrap().pop().push(&last); + // Repos can generally be accessed with or without `.git` extension. + let needs_chopping = url.path().ends_with(".git"); + if needs_chopping { + let last = { + let last = url.path_segments().unwrap().next_back().unwrap(); + last[..last.len() - 4].to_owned() + }; + url.path_segments_mut().unwrap().pop().push(&last); + } } Ok(CanonicalUrl(url))
tests/testsuite/registry.rs+17 −22 modified@@ -3276,8 +3276,7 @@ fn package_lock_inside_package_is_overwritten() { } #[cargo_test] -fn package_lock_as_a_symlink_inside_package_is_overwritten() { - let registry = registry::init(); +fn package_lock_as_a_symlink_inside_package_is_invalid() { let p = project() .file( "Cargo.toml", @@ -3300,21 +3299,23 @@ fn package_lock_as_a_symlink_inside_package_is_overwritten() { .symlink(".cargo-ok", "src/lib.rs") .publish(); - p.cargo("check").run(); + p.cargo("check") + .with_status(101) + .with_stderr_data(str![[r#" +[UPDATING] `dummy-registry` index +[LOCKING] 1 package to latest compatible version +[DOWNLOADING] crates ... +[DOWNLOADED] bar v0.0.1 (registry `dummy-registry`) +[ERROR] failed to download replaced source registry `crates-io` - let id = SourceId::for_registry(registry.index_url()).unwrap(); - let hash = cargo::util::hex::short_hash(&id); - let pkg_root = paths::cargo_home() - .join("registry") - .join("src") - .join(format!("-{}", hash)) - .join("bar-0.0.1"); - let ok = pkg_root.join(".cargo-ok"); - let librs = pkg_root.join("src/lib.rs"); +Caused by: + failed to unpack package `bar v0.0.1 (registry `dummy-registry`)` - // Is correctly overwritten and doesn't affect the file linked to - assert_eq!(ok.metadata().unwrap().len(), 7); - assert_eq!(fs::read_to_string(librs).unwrap(), "pub fn f() {}"); +Caused by: + invalid tarball downloaded, contains an entry at "bar-0.0.1/.cargo-ok" with invalid type Symlink + +"#]]) + .run(); } #[cargo_test] @@ -4770,13 +4771,7 @@ Caused by: failed to unpack package `bar v1.0.0 (registry `dummy-registry`)` Caused by: - failed to unpack entry at `bar-1.0.0/smuggled` - -Caused by: - failed to unpack `[ROOT]/home/.cargo/registry/src/-[HASH]/bar-1.0.0/smuggled` - -Caused by: - [..] when creating dir [ROOT]/home/.cargo/registry/src/-[HASH]/bar-1.0.0/smuggled + invalid tarball downloaded, contains an entry at "bar-1.0.0/smuggled" with invalid type Symlink "#]]) .run();
Vulnerability mechanics
Root cause
"Missing scheme check in URL canonicalization caused `.git` stripping and hostname lowercasing to be applied to sparse registry URLs, allowing distinct registries on the same domain to collide."
Attack vector
An attacker who can publish crates in a third-party sparse registry that shares a domain with other registries can exploit the fact that Cargo incorrectly normalized the registry URL. Because the canonicalization stripped `.git` suffixes and lowercased the hostname even for sparse URLs, two distinct registries on the same domain could produce the same canonical URL, causing Cargo to reuse credentials intended for one registry when accessing the other. The attack requires a hosting provider that allows multiple registries with arbitrary names under the same domain, and the attacker must be able to publish crates in one of those registries.
Affected code
The vulnerability lies in `src/cargo/util/canonical_url.rs`, where the `CanonicalUrl` logic unconditionally stripped `.git` suffixes and lowercased hostnames for all URLs, including sparse registry URLs (which use a `sparse+https` scheme). The patch wraps these normalizations inside a check for `!url.scheme().contains('+')`, ensuring they only apply to git-based registries.
What the fix does
The patch modifies `canonical_url.rs` to conditionally apply `.git` stripping and hostname lowercasing only when the URL scheme does not contain a `+` character (i.e., only for git URLs, not sparse URLs). A corresponding test in `src/cargo/sources/git/source.rs` verifies that `sparse+https://crates.io/fake-registry` and `sparse+https://crates.io/fake-registry.git` now produce different canonical identifiers. This ensures that distinct sparse registries on the same domain are not incorrectly treated as the same registry, preventing credential leakage.
Preconditions
- configThe hosting provider must allow multiple registries with arbitrary names under the same domain.
- authThe attacker must be able to publish crates in one of the registries on that domain.
- inputThe victim must use a Cargo version between 1.68 and 1.96 with a sparse registry configured on that domain.
Generated on May 25, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3- github.com/rust-lang/cargo/pull/17031mitrepatch
- blog.rust-lang.org/2026/05/25/cve-2026-5222/mitrevendor-advisory
- groups.google.com/g/rustlang-security-announcements/c/SfUxOiIdY5smitrevendor-advisorymailing-list
News mentions
0No linked articles in our index yet.