Terminating targets role delegations are not respected in tough
Description
Missing validation of terminating delegation causes the client to continue searching the defined delegation list, even after searching a terminating delegation. This could cause the client to fetch a target from an incorrect source, altering the target contents. Users should upgrade to tough version 0.20.0 or later and ensure any forked or derivative code is patched to incorporate the new fixes.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Missing validation of terminating delegation in tough allows client to search beyond terminating delegation, potentially fetching targets from incorrect source.
Vulnerability
Details
CVE-2025-2886 is a vulnerability in the tough library, a Rust client for The Update Framework (TUF) repositories. The issue stems from missing validation of terminating delegation: when a terminating delegation is encountered, the client should stop searching and use the target from that delegation. However, due to the flaw, the client continues to search the delegation list after processing a terminating delegation. This can result in the client fetching a target from an unintended source, altering the target's contents.[1][2]
Exploitation
An attacker with the ability to control or influence a delegated role's metadata could potentially exploit this vulnerability. By crafting a delegation chain where a terminating delegation is followed by another delegation that points to malicious content, the client may end up using the malicious target instead of the intended one. No authentication is required beyond what is needed to provide metadata updates; the client's TUF library would process the malicious delegation chain if it is properly signed by an authorized key.[2]
Impact
Successful exploitation could allow an attacker to serve modified or malicious content to clients that rely on tough for secure updates. This could lead to arbitrary code execution if the target is an executable or script, or other integrity violations depending on the nature of the updated artifact. The confidentiality, integrity, and availability of systems depending on tough could be compromised.[1][2]
Mitigation
The vulnerability is patched in tough version 0.20.0. Users are strongly recommended to upgrade to this version or later. The fix involves properly handling terminating delegations by stopping delegation chain traversal upon encountering a terminating delegation, as shown in the commit that adds explicit false flags for terminating roles.[3][4] Any forked or derivative code should be updated to incorporate these changes. No workarounds are known; upgrading is the only effective mitigation.[1][2]
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
toughcrates.io | < 0.20.0 | 0.20.0 |
Affected products
3- AWS/toughv5Range: 0.1.0
Patches
1598111f88105tough: implement terminating delegations
9 files changed · +85 −19
tough/src/editor/mod.rs+4 −1 modified@@ -352,6 +352,7 @@ impl RepositoryEditor { name: &str, key_source: &[Box<dyn KeySource>], paths: PathSet, + terminating: bool, threshold: NonZeroU64, expiration: DateTime<Utc>, version: NonZeroU64, @@ -387,6 +388,7 @@ impl RepositoryEditor { self.targets_editor_mut()?.delegate_role( new_targets, paths, + terminating, key_pairs, keyids, threshold, @@ -646,6 +648,7 @@ impl RepositoryEditor { name: &str, metadata_url: &str, paths: PathSet, + terminating: bool, threshold: NonZeroU64, keys: Option<HashMap<Decoded<Hex>, Key>>, ) -> Result<&mut Self> { @@ -658,7 +661,7 @@ impl RepositoryEditor { self.targets_editor_mut()?.limits(limits); self.targets_editor_mut()?.transport(transport.clone()); self.targets_editor_mut()? - .add_role(name, metadata_url, paths, threshold, keys) + .add_role(name, metadata_url, paths, terminating, threshold, keys) .await?; Ok(self)
tough/src/editor/targets.rs+11 −2 modified@@ -336,6 +336,7 @@ impl TargetsEditor { &mut self, targets: Signed<DelegatedTargets>, paths: PathSet, + terminating: bool, key_pairs: HashMap<Decoded<Hex>, Key>, keyids: Vec<Decoded<Hex>>, threshold: NonZeroU64, @@ -348,7 +349,7 @@ impl TargetsEditor { paths, keyids, threshold, - terminating: false, + terminating, targets: Some(Signed { signed: targets.signed.targets, signatures: targets.signatures, @@ -388,6 +389,7 @@ impl TargetsEditor { name: &str, metadata_url: &str, paths: PathSet, + terminating: bool, threshold: NonZeroU64, keys: Option<HashMap<Decoded<Hex>, Key>>, ) -> Result<&mut Self> { @@ -446,7 +448,14 @@ impl TargetsEditor { (key_pairs.keys().cloned().collect(), key_pairs) }; - self.delegate_role(delegated_targets, paths, key_pairs, keyids, threshold)?; + self.delegate_role( + delegated_targets, + paths, + terminating, + key_pairs, + keyids, + threshold, + )?; Ok(self) }
tough/src/lib.rs+23 −10 modified@@ -483,12 +483,20 @@ impl Repository { // HASH is one of the hashes of the targets file listed in the targets metadata file // found earlier in step 4. In either case, the client MUST write the file to // non-volatile storage as FILENAME.EXT. - Ok(if let Ok(target) = self.targets.signed.find_target(name) { - let (sha256, file) = self.target_digest_and_filename(target, name); - Some(self.fetch_target(target, &sha256, file.as_str()).await?) - } else { - None - }) + let mut visited_roles: BTreeSet<String> = BTreeSet::new(); + let mut terminated = false; + Ok( + if let Ok(target) = + self.targets + .signed + .find_target(name, &mut visited_roles, &mut terminated, false) + { + let (sha256, file) = self.target_digest_and_filename(target, name); + Some(self.fetch_target(target, &sha256, file.as_str()).await?) + } else { + None + }, + ) } /// Fetches a target from the repository and saves it to `outdir`. Attempts to do this as safely @@ -535,11 +543,15 @@ impl Repository { let filename = match prepend { Prefix::Digest => { - let target = self.targets.signed.find_target(name).with_context(|_| { - error::CacheTargetMissingSnafu { + let mut visited_roles: BTreeSet<String> = BTreeSet::new(); + let mut terminated = false; + let target = self + .targets + .signed + .find_target(name, &mut visited_roles, &mut terminated, false) + .with_context(|_| error::CacheTargetMissingSnafu { target_name: name.clone(), - } - })?; + })?; let sha256 = target.hashes.sha256.clone().into_vec(); format!("{}.{}", hex::encode(sha256), name.resolved()) } @@ -1220,6 +1232,7 @@ async fn load_targets( } // Follow the paths of delegations starting with the top level targets.json delegation +#[allow(clippy::too_many_arguments)] #[async_recursion] async fn load_delegations( transport: &dyn Transport,
tough/src/schema/mod.rs+30 −6 modified@@ -27,7 +27,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; use serde_plain::{derive_display_from_serialize, derive_fromstr_from_deserialize}; use snafu::ResultExt; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::num::NonZeroU64; use std::ops::{Deref, DerefMut}; use std::path::Path; @@ -516,21 +516,43 @@ impl Targets { /// /// **Caution**: does not imply that delegations in this struct or any child are valid. /// - pub fn find_target(&self, target_name: &TargetName) -> Result<&Target> { + pub fn find_target( + &self, + target_name: &TargetName, + visited: &mut BTreeSet<String>, + terminated: &mut bool, + permissive: bool, + ) -> Result<&Target> { if let Some(target) = self.targets.get(target_name) { return Ok(target); } if let Some(delegations) = &self.delegations { for role in &delegations.roles { // If the target cannot match this DelegatedRole, then we do not want to recurse and - // check any of its child roles either. - if !role.paths.matches_target_name(target_name) { + // check any of its child roles either. If we have already visited this role, we + // do not need to visit it again. + if !role.paths.matches_target_name(target_name) || visited.contains(&role.name) { continue; } + visited.insert(role.name.clone()); if let Some(targets) = &role.targets { - if let Ok(target) = targets.signed.find_target(target_name) { + if let Ok(target) = + targets + .signed + .find_target(target_name, visited, terminated, permissive) + { return Ok(target); } + if !permissive && *terminated { + // we encountered a terminating delegation, so we stop iterating immediately + break; + } + } + if role.terminating && !permissive { + // this role was terminating, so set terminated. This will cause all ancestors + // to stop iterating and return not-found. + *terminated = true; + break; } } } @@ -724,7 +746,9 @@ impl Targets { /// that the ownership of each target is valid. pub(crate) fn validate(&self) -> Result<()> { for (target_name, _) in self.targets_iter() { - self.find_target(target_name)?; + let mut terminated = false; + let mut visited_roles: BTreeSet<String> = BTreeSet::new(); + self.find_target(target_name, &mut visited_roles, &mut terminated, true)?; } Ok(()) }
tough/tests/data/delegation-targets/a.txt+1 −0 added@@ -0,0 +1 @@ +This is delegated a.txt \ No newline at end of file
tough/tests/data/delegation-targets/b.txt+1 −0 added@@ -0,0 +1 @@ +This is delegation b.txt \ No newline at end of file
tough/tests/repo_editor.rs+8 −0 modified@@ -169,6 +169,7 @@ async fn create_sign_write_reload_repo() { "role1", role1_key, PathSet::Paths(vec![PathPattern::new("file?.txt").unwrap()]), + false, NonZeroU64::new(1).unwrap(), Utc::now().checked_add_signed(days(21)).unwrap(), NonZeroU64::new(1).unwrap(), @@ -189,6 +190,7 @@ async fn create_sign_write_reload_repo() { "role2", role2_key, PathSet::Paths(vec![PathPattern::new("file1.txt").unwrap()]), + false, NonZeroU64::new(1).unwrap(), Utc::now().checked_add_signed(days(21)).unwrap(), NonZeroU64::new(1).unwrap(), @@ -199,6 +201,7 @@ async fn create_sign_write_reload_repo() { "role3", role1_key, PathSet::Paths(vec![PathPattern::new("file1.txt").unwrap()]), + false, NonZeroU64::new(1).unwrap(), Utc::now().checked_add_signed(days(21)).unwrap(), NonZeroU64::new(1).unwrap(), @@ -219,6 +222,7 @@ async fn create_sign_write_reload_repo() { "role4", role2_key, PathSet::Paths(vec![PathPattern::new("file1.txt").unwrap()]), + false, NonZeroU64::new(1).unwrap(), Utc::now().checked_add_signed(days(21)).unwrap(), NonZeroU64::new(1).unwrap(), @@ -312,6 +316,7 @@ async fn create_role_flow() { "A", metadata_base_url_out.as_str(), PathSet::Paths(vec![PathPattern::new("*.txt").unwrap()]), + false, NonZeroU64::new(1).unwrap(), Some(key_hash_map(role1_key).await), ) @@ -396,6 +401,7 @@ async fn create_role_flow() { "B", metadata_base_url_out.as_str(), PathSet::Paths(vec![PathPattern::new("file?.txt").unwrap()]), + false, NonZeroU64::new(1).unwrap(), Some(key_hash_map(role2_key).await), ) @@ -536,6 +542,7 @@ async fn update_targets_flow() { "A", metadata_base_url_out.as_str(), PathSet::Paths(vec![PathPattern::new("*.txt").unwrap()]), + false, NonZeroU64::new(1).unwrap(), Some(key_hash_map(role1_key).await), ) @@ -620,6 +627,7 @@ async fn update_targets_flow() { "B", metadata_base_url_out.as_str(), PathSet::Paths(vec![PathPattern::new("file?.txt").unwrap()]), + false, NonZeroU64::new(1).unwrap(), Some(key_hash_map(role2_key).await), )
tough/tests/target_path_safety.rs+1 −0 modified@@ -91,6 +91,7 @@ async fn safe_target_paths() { "delegated", &keys, PathSet::Paths(vec![PathPattern::new("delegated/*").unwrap()]), + false, one, later(), one,
tuftool/src/add_role.rs+6 −0 modified@@ -45,6 +45,10 @@ pub(crate) struct AddRoleArgs { #[arg(short, long, conflicts_with = "path_hash_prefixes")] paths: Option<Vec<PathPattern>>, + /// Make the delegation terminating + #[arg(long)] + terminating: bool, + /// Path to root.json file for the repository #[arg(short, long)] root: PathBuf, @@ -130,6 +134,7 @@ impl AddRoleArgs { &self.delegatee, self.indir.as_str(), paths, + self.terminating, self.threshold, None, ) @@ -205,6 +210,7 @@ impl AddRoleArgs { &self.delegatee, self.indir.as_str(), paths, + self.terminating, self.threshold, None, )
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/awslabs/tough/releases/tag/tough-v0.20.0ghsapatchWEB
- aws.amazon.com/security/security-bulletins/AWS-2025-007/mitrevendor-advisory
- github.com/advisories/GHSA-v4wr-j3w6-mxqcghsaADVISORY
- github.com/awslabs/tough/security/advisories/GHSA-v4wr-j3w6-mxqcghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2025-2886ghsaADVISORY
- aws.amazon.com/security/security-bulletins/AWS-2025-007ghsaWEB
- github.com/awslabs/tough/commit/598111f88105a707ee68b0fa06c52da7176ea96aghsaWEB
News mentions
0No linked articles in our index yet.