Skip to content

Commit 7f9cb9e

Browse files
unhappychoiceclaude
andcommitted
fix: address security vulnerabilities in cache implementation
- Include commit hash and dirty state in cache file hash to prevent stale/ambiguous entries - Treat partial challenge reconstruction failures as cache miss to ensure data integrity - Add path traversal protection in reconstruct_challenge using canonicalize - Update list_keys to use repo_key from CacheData instead of filename - Add sha2 dependency for secure cache file naming 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 730ced1 commit 7f9cb9e

File tree

3 files changed

+120
-16
lines changed

3 files changed

+120
-16
lines changed

Cargo.lock

Lines changed: 72 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ reqwest = { version = "0.12", features = ["json"] }
7474
atty = "0.2"
7575
bincode = "1.3"
7676
flate2 = "1.0"
77+
sha2 = "0.10"
7778

7879
[dev-dependencies]
7980
paste = "1.0"

src/cache/challenge_cache.rs

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ struct ChallengePointer {
1616

1717
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1818
struct CacheData {
19+
/// For display/CLI purposes; filename becomes opaque once hashed.
20+
repo_key: String,
1921
commit_hash: String,
2022
challenge_pointers: Vec<ChallengePointer>,
2123
}
@@ -44,6 +46,12 @@ impl ChallengeCache {
4446
return Ok(());
4547
}
4648

49+
// Do not save when the commit hash is unknown
50+
let commit_str = match repo.commit_hash.as_deref() {
51+
Some(h) if !h.is_empty() => h,
52+
_ => return Ok(()),
53+
};
54+
4755
let cache_file = self.get_cache_file(repo);
4856

4957
let challenge_pointers: Vec<ChallengePointer> = challenges
@@ -60,7 +68,8 @@ impl ChallengeCache {
6068
.collect();
6169

6270
let cache_data = CacheData {
63-
commit_hash: repo.commit_hash.clone().unwrap_or_default(),
71+
repo_key: repo.cache_key(),
72+
commit_hash: commit_str.to_string(),
6473
challenge_pointers,
6574
};
6675

@@ -93,10 +102,10 @@ impl ChallengeCache {
93102
let total = cache_data.challenge_pointers.len();
94103
let processed = Arc::new(Mutex::new(0usize));
95104

96-
let challenges: Vec<Challenge> = cache_data
105+
let results: Vec<Option<Challenge>> = cache_data
97106
.challenge_pointers
98107
.par_iter()
99-
.filter_map(|pointer| {
108+
.map(|pointer| {
100109
let challenge = self.reconstruct_challenge(pointer, repo_root);
101110

102111
// Report progress atomically
@@ -118,6 +127,10 @@ impl ChallengeCache {
118127
})
119128
.collect();
120129

130+
if results.iter().any(|r| r.is_none()) {
131+
return None;
132+
}
133+
let challenges: Vec<Challenge> = results.into_iter().map(Option::unwrap).collect();
121134
Some(challenges)
122135
}
123136

@@ -172,22 +185,22 @@ impl ChallengeCache {
172185
return Ok(Vec::new());
173186
}
174187

175-
let keys = fs::read_dir(&self.cache_dir)
188+
let mut keys: Vec<String> = fs::read_dir(&self.cache_dir)
176189
.map_err(|e| format!("Failed to read cache dir: {}", e))?
177190
.filter_map(|entry| entry.ok())
178191
.filter_map(|entry| {
179-
entry
180-
.file_name()
181-
.to_str()
182-
.filter(|name| name.ends_with(".bin"))
183-
.and_then(|name| {
184-
let cache_key = name.trim_end_matches(".bin");
185-
GzipStorage::load::<CacheData>(&entry.path())
186-
.map(|cache_data| format!("{}:{}", cache_key, cache_data.commit_hash))
187-
})
192+
// Prefer data inside the file (repo_key + commit_hash) instead of filename.
193+
if entry.file_name().to_string_lossy().ends_with(".bin") {
194+
GzipStorage::load::<CacheData>(&entry.path())
195+
.map(|d| format!("{}:{}", d.repo_key, d.commit_hash))
196+
} else {
197+
None
198+
}
188199
})
189200
.collect();
190201

202+
keys.sort();
203+
keys.dedup();
191204
Ok(keys)
192205
}
193206

@@ -198,6 +211,11 @@ impl ChallengeCache {
198211
) -> Option<Challenge> {
199212
let file_path = pointer.source_file_path.as_ref()?;
200213
let absolute_path = repo_root.join(file_path);
214+
let absolute_path = absolute_path.canonicalize().ok()?;
215+
let repo_root = repo_root.canonicalize().ok()?;
216+
if !absolute_path.starts_with(&repo_root) {
217+
return None;
218+
}
201219

202220
// Read file content
203221
let file_content = fs::read_to_string(&absolute_path).ok()?;
@@ -228,9 +246,22 @@ impl ChallengeCache {
228246
}
229247

230248
fn get_cache_file(&self, repo: &GitRepository) -> PathBuf {
231-
fs::create_dir_all(&self.cache_dir).ok();
232-
let cache_key = repo.cache_key();
233-
self.cache_dir.join(format!("{}.bin", cache_key))
249+
// Best-effort dir creation; callers handle save/load errors.
250+
let _ = fs::create_dir_all(&self.cache_dir);
251+
// Compose a stable, collision-resistant, filesystem-safe key.
252+
let commit = repo.commit_hash.as_deref().unwrap_or("nohash");
253+
let dirty = if repo.is_dirty { "dirty" } else { "clean" };
254+
let raw = format!("{}:{}:{}", repo.cache_key(), commit, dirty);
255+
// Hash to keep filename short and safe across OSes.
256+
use sha2::{Digest, Sha256};
257+
let mut hasher = Sha256::new();
258+
hasher.update(raw.as_bytes());
259+
let digest = hasher.finalize();
260+
let hex = digest
261+
.iter()
262+
.map(|b| format!("{:02x}", b))
263+
.collect::<String>();
264+
self.cache_dir.join(format!("{}.bin", hex))
234265
}
235266
}
236267

0 commit comments

Comments
 (0)