fix(cache-memory): reject symlinks in agent memory to prevent Stage 3 credential theft#524
Conversation
… credential theft Three-part defense against the symlink-following attack described in the security audit: 1. collect_files: use entry.file_type() (does NOT follow symlinks) instead of path.is_dir() when deciding whether to recurse. Both file symlinks and directory symlinks are now skipped with a warning, so they are never added to the file list at all. 2. process_agent_memory: canonicalize the memory source base directory once before the per-file loop, then canonicalize each source file path and verify it starts_with the canonical base. This TOCTOU guard catches any symlink that slips through the collection phase (e.g., via a race condition) before any read or copy takes place. 3. Tests (Unix-only): added four new #[cfg(unix)] async tests that create real symlinks on disk and confirm: - collect_files skips file symlinks - collect_files skips directory symlinks (no recursion) - process_agent_memory skips file symlinks pointing outside the base - process_agent_memory skips directory symlinks pointing outside the base Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/efdc2afe-1050-4068-8433-cd9a60762981 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
- Add clarifying comment on else-if branch in collect_files explaining it is only reached for real (non-symlink) directories - Improve canonicalize error message to explain the security purpose - Add test_collect_files_skips_relative_symlinks covering the relative symlink attack vector (e.g. ../secret.txt) - Fix assertion style: 'must NOT' -> 'must not' Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/efdc2afe-1050-4068-8433-cd9a60762981 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
|
/rust-review |
|
✅ Rust PR Reviewer completed successfully! |
🔍 Rust PR ReviewSummary: Good fix for a real security vulnerability — the in-directory symlink defense is sound — but there is one remaining gap that could allow a bypass of the entire containment check. Findings🐛 Bugs / Logic Issues
if !memory_source.exists() || !memory_source.is_dir() {Both
The PR description notes that ADO artifacts are tar-based and preserve symlinks — so a top-level Suggested fix — check symlink status before the // Use lstat-equivalent to avoid following symlinks for the base directory.
let meta = tokio::fs::symlink_metadata(&memory_source).await;
match meta {
Err(_) => {
info!("No agent_memory directory found, skipping memory processing");
return Ok(ExecutionResult::success("No agent memory to process"));
}
Ok(m) if m.is_symlink() => {
warn!("agent_memory is a symlink — skipping to prevent directory escape");
return Ok(ExecutionResult::success("No agent memory to process"));
}
Ok(m) if !m.is_dir() => {
info!("No agent_memory directory found, skipping memory processing");
return Ok(ExecutionResult::success("No agent memory to process"));
}
Ok(_) => {} // real directory, proceed
}✅ What Looks Good
|
|
@copilot address feedback |
The existing fix guarded against symlinks *inside* agent_memory, but if the agent_memory entry itself is a directory symlink the old `is_dir()` check follows it (returning true), and canonicalize() resolves relative to the symlink target — so every collected file passes the starts_with guard and sensitive files are copied. Fix: replace `exists()/is_dir()` with `symlink_metadata()` (lstat) on memory_source and explicitly reject the symlink case before any further processing. Also adds test_process_memory_rejects_base_directory_symlink which plants a `agent_memory -> /sensitive/dir` symlink and asserts nothing is copied to the output. Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/971a1892-4711-4142-98ad-d85574576689 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Fixed in 91fae19. The Rust reviewer correctly identified that The fix replaces |
Stage 3's
process_agent_memoryfollowed symlinks unconditionally, allowing a Stage 1 agent to plant a symlink (e.g.env.txt -> /proc/self/environ) in the memory staging directory that survives the artifact round-trip (ADO pipeline artifacts are tar-based and preserve symlinks). On Stage 3, the symlink would be read and copied into the output artifact, exfiltratingSC_WRITE_TOKENand other write-capable credentials into the agent's next-run memory — breaking the core security invariant of the three-stage model.A second vector was also closed: if the entire
agent_memory/entry in the artifact is itself a symlink to an outside directory, the oldis_dir()check would follow it (returningtrue), thencanonicalize()would resolve relative to the symlink target — causing every collected file to pass thestarts_withcontainment guard and sensitive files to be copied to output.Summary
collect_files— stop following symlinks during enumerationpath.is_dir()(follows symlinks) withentry.file_type().await?(uses rawreaddirresult, never dereferences). Both file symlinks and directory symlinks are skipped with awarn!and never added to the collected file list.process_agent_memory— reject symlink at base directory levelexists()/is_dir()withtokio::fs::symlink_metadata()(lstat) onmemory_source. A symlink at theagent_memory/level is now detected and rejected before any further processing, closing the bypass wherecanonicalize()would resolve to the symlink target and make all collected files pass the containment check.process_agent_memory— TOCTOU canonicalization guardstarts_withthe canonical base. Catches any symlink that races past the collection phase before any read or copy occurs.Tests (Unix-only, 6 new
#[cfg(unix)])test_collect_files_skips_file_symlinks— absolute-target file symlink not collectedtest_collect_files_skips_relative_symlinks— relative-target symlink (../secret.txt) not collectedtest_collect_files_skips_directory_symlinks— directory symlink not recursedtest_process_memory_skips_file_symlinks— sensitive data from symlink target never copied end-to-endtest_process_memory_skips_directory_symlinks— directory symlink contents never copied end-to-endtest_process_memory_rejects_base_directory_symlink— top-levelagent_memory -> /sensitive/dirsymlink rejected, nothing copiedTest plan
cargo test --bin ado-aw cache_memory— all 31 tests pass (6 new).