diff --git a/Cargo.lock b/Cargo.lock index e907980..59d946c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2266,7 +2266,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.72.5" +version = "0.72.6" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 1a2a9b6..2fc3c37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.72.5" +version = "0.72.6" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 4db09ff..7c06c1d 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -4323,9 +4323,10 @@ mod tests { } #[tokio::test] - async fn remove_cached_rejects_path_traversal() { - // A non-hex store id that tries to escape the cache dir is refused and - // never deletes anything outside it. + async fn remove_cached_rejects_a_non_canonical_key_at_the_validator() { + // A non-hex store id is refused by the 64-hex validator (`CapsuleKey::parse`) BEFORE any path + // is built — so it never reaches the containment guard below. This pins the validator gate; + // `remove_cached_containment_guard_refuses_a_symlink_escape` pins the guard that follows it. let (node, _td) = test_node(None); let err = node .cache_remove_cached("../../etc", &"33".repeat(32)) @@ -4333,7 +4334,42 @@ mod tests { .unwrap_err(); assert!( err.contains("invalid") || err.contains("hex"), - "traversal attempt rejected as invalid input, got: {err}" + "a non-canonical key is rejected as invalid input, got: {err}" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn remove_cached_containment_guard_refuses_a_symlink_escape() { + // The 64-hex validator can only pass keys whose bytes contain no `.`/`/`, so a valid key can + // never escape `/modules` on its own — the canonicalize + `starts_with(cache)` guard is + // defense-in-depth against a compromised cache LAYOUT. Exercise it directly: plant a symlink + // inside the cache whose target is a real file OUTSIDE the cache, then a well-formed remove + // must REFUSE it and leave the outside file intact. Delete the guard and this unlinks the + // outside file instead — so the assertions below fail without it (the test pins the guard). + let (node, _td) = test_node(None); + let store = "aa".repeat(32); + let root = "bb".repeat(32); + + // A real file outside the cache dir that must survive the refused remove. + let outside = tempfile::tempdir().unwrap(); + let protected = outside.path().join(format!("{root}.module")); + std::fs::write(&protected, b"must-not-be-deleted").unwrap(); + + // /modules/ -> , so /modules//.module resolves, + // through the symlink, to the protected file above. + let modules = node.cache_dir_path().join("modules"); + std::fs::create_dir_all(&modules).unwrap(); + std::os::unix::fs::symlink(outside.path(), modules.join(&store)).unwrap(); + + let err = node.cache_remove_cached(&store, &root).await.unwrap_err(); + assert!( + err.contains("outside the cache"), + "a symlink escape is refused by the containment guard, got: {err}" + ); + assert!( + protected.exists(), + "the containment guard must leave the out-of-cache file intact" ); }