Skip to content

Commit afbc233

Browse files
authored
fix(file-saver): block symlink escapes on save (#103)
## What Harden `LocalFileSaver` against symlink-based escapes under `base_dir`. Closes #97. ## Why Lexical normalization blocked `..` traversal, but a pre-existing symlink inside the save root could still redirect writes outside the allowed directory. `execute_with_saver()` also split validation and write into separate steps, which left a race window. ## How - walk each parent directory at save time under the canonical base directory - reject symlinked parent components and symlink final targets before writing - keep the canonicalized path pinned under the canonical base directory - remove the separate `validate_path()` preflight from `execute_with_saver()` so checks happen at write time - add regression coverage for direct saver writes and `execute_with_saver()` symlink escapes - update the threat model to mark this path as mitigated ## Risk - Low - Touches only file-save path handling and adds regression tests around the changed behavior ### Checklist - [x] Unit tests are passed - [x] Smoke tests are passed - [ ] Documentation is updated - [x] Specs are up to date and not in conflict - [x] `cargo fmt --all` is passed - [x] `cargo clippy --workspace --all-targets -- -D warnings` is passed - [x] `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` is passed - [x] `cargo build --workspace --exclude fetchkit-python --release` is passed
1 parent 987504d commit afbc233

4 files changed

Lines changed: 184 additions & 20 deletions

File tree

crates/fetchkit/src/file_saver.rs

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ impl LocalFileSaver {
9090

9191
/// Resolve and validate a path, returning the normalized absolute path.
9292
fn resolve_path(&self, path: &str) -> Result<PathBuf, FileSaveError> {
93+
if path.is_empty() {
94+
return Err(FileSaveError::PathNotAllowed(
95+
"Path must name a file".into(),
96+
));
97+
}
98+
9399
let input = PathBuf::from(path);
94100

95101
if let Some(base) = &self.base_dir {
@@ -117,22 +123,134 @@ impl LocalFileSaver {
117123
Ok(normalize_path(&input))
118124
}
119125
}
126+
127+
async fn canonicalize_base_dir(&self, base: &Path) -> Result<PathBuf, FileSaveError> {
128+
tokio::fs::create_dir_all(base).await?;
129+
130+
let meta = tokio::fs::symlink_metadata(base).await?;
131+
if meta.file_type().is_symlink() {
132+
return Err(FileSaveError::PathNotAllowed(
133+
"Base directory must not be a symlink".into(),
134+
));
135+
}
136+
if !meta.is_dir() {
137+
return Err(FileSaveError::PathNotAllowed(
138+
"Base directory must be a directory".into(),
139+
));
140+
}
141+
142+
Ok(tokio::fs::canonicalize(base).await?)
143+
}
144+
145+
async fn prepare_parent_dir(&self, resolved: &Path) -> Result<PathBuf, FileSaveError> {
146+
let Some(base) = &self.base_dir else {
147+
return Ok(resolved
148+
.parent()
149+
.ok_or_else(|| FileSaveError::PathNotAllowed("Path must name a file".into()))?
150+
.to_path_buf());
151+
};
152+
153+
let normalized_base = normalize_path(base);
154+
let relative = resolved
155+
.strip_prefix(&normalized_base)
156+
.map_err(|_| FileSaveError::PathNotAllowed("Path escapes base directory".into()))?;
157+
let canonical_base = self.canonicalize_base_dir(base).await?;
158+
let mut current = canonical_base.clone();
159+
160+
for component in relative
161+
.parent()
162+
.unwrap_or_else(|| Path::new(""))
163+
.components()
164+
{
165+
let Component::Normal(name) = component else {
166+
return Err(FileSaveError::PathNotAllowed(format!(
167+
"Unsupported path component in save path: {}",
168+
resolved.display()
169+
)));
170+
};
171+
172+
let candidate = current.join(name);
173+
let meta = match tokio::fs::symlink_metadata(&candidate).await {
174+
Ok(meta) => meta,
175+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
176+
if let Err(create_err) = tokio::fs::create_dir(&candidate).await {
177+
if create_err.kind() != std::io::ErrorKind::AlreadyExists {
178+
return Err(create_err.into());
179+
}
180+
}
181+
tokio::fs::symlink_metadata(&candidate).await?
182+
}
183+
Err(err) => return Err(err.into()),
184+
};
185+
186+
if meta.file_type().is_symlink() {
187+
return Err(FileSaveError::PathNotAllowed(format!(
188+
"Path traverses symlink: {}",
189+
candidate.display()
190+
)));
191+
}
192+
if !meta.is_dir() {
193+
return Err(FileSaveError::PathNotAllowed(format!(
194+
"Parent path is not a directory: {}",
195+
candidate.display()
196+
)));
197+
}
198+
199+
let canonical_candidate = tokio::fs::canonicalize(&candidate).await?;
200+
if !canonical_candidate.starts_with(&canonical_base) {
201+
return Err(FileSaveError::PathNotAllowed(format!(
202+
"Path escapes base directory via symlink: {}",
203+
candidate.display()
204+
)));
205+
}
206+
current = canonical_candidate;
207+
}
208+
209+
Ok(current)
210+
}
120211
}
121212

122213
#[async_trait]
123214
impl FileSaver for LocalFileSaver {
124215
async fn save(&self, path: &str, bytes: &[u8]) -> Result<SaveResult, FileSaveError> {
125216
let resolved = self.resolve_path(path)?;
217+
if let Some(base_dir) = &self.base_dir {
218+
if resolved == normalize_path(base_dir) {
219+
return Err(FileSaveError::PathNotAllowed(
220+
"Path must name a file".into(),
221+
));
222+
}
223+
}
224+
let file_name = resolved
225+
.file_name()
226+
.ok_or_else(|| FileSaveError::PathNotAllowed("Path must name a file".into()))?;
227+
let parent_dir = self.prepare_parent_dir(&resolved).await?;
228+
let final_path = parent_dir.join(file_name);
229+
230+
if self.base_dir.is_none() {
231+
if let Some(parent) = final_path.parent() {
232+
tokio::fs::create_dir_all(parent).await?;
233+
}
234+
}
126235

127-
// Create parent directories
128-
if let Some(parent) = resolved.parent() {
129-
tokio::fs::create_dir_all(parent).await?;
236+
match tokio::fs::symlink_metadata(&final_path).await {
237+
Ok(meta) if meta.file_type().is_symlink() => {
238+
return Err(FileSaveError::PathNotAllowed(format!(
239+
"Refusing to write through symlink: {}",
240+
final_path.display()
241+
)));
242+
}
243+
Ok(_) => {}
244+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
245+
Err(err) => return Err(err.into()),
130246
}
131247

132-
tokio::fs::write(&resolved, bytes).await?;
248+
// THREAT[TM-INPUT-008]: Validate and create the final path during save,
249+
// so symlink checks happen at write time rather than in a separate preflight step.
250+
tokio::fs::write(&final_path, bytes).await?;
133251

134252
Ok(SaveResult {
135-
path: resolved.to_string_lossy().to_string(),
253+
path: final_path.to_string_lossy().to_string(),
136254
bytes_written: bytes.len() as u64,
137255
})
138256
}

crates/fetchkit/src/tool.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -532,18 +532,13 @@ impl Tool {
532532
req: FetchRequest,
533533
saver: Option<&dyn FileSaver>,
534534
) -> Result<FetchResponse, FetchError> {
535-
if let Some(path) = &req.save_to_file {
535+
if req.save_to_file.is_some() {
536536
if !self.enable_save_to_file {
537537
return Err(FetchError::SaverNotAvailable);
538538
}
539539

540540
let saver = saver.ok_or(FetchError::SaverNotAvailable)?;
541541

542-
saver
543-
.validate_path(path)
544-
.await
545-
.map_err(|e| FetchError::SaveError(e.to_string()))?;
546-
547542
let options = self.build_options();
548543
let registry = FetcherRegistry::with_defaults();
549544
registry.fetch_to_file(req, options, saver).await

crates/fetchkit/tests/file_saver_safety.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ fn saver_in(dir: &std::path::Path) -> LocalFileSaver {
2323
LocalFileSaver::new(Some(dir.to_path_buf()))
2424
}
2525

26+
#[cfg(unix)]
27+
fn symlink_dir(src: &std::path::Path, dst: &std::path::Path) {
28+
std::os::unix::fs::symlink(src, dst).unwrap();
29+
}
30+
31+
#[cfg(windows)]
32+
fn symlink_dir(src: &std::path::Path, dst: &std::path::Path) {
33+
std::os::windows::fs::symlink_dir(src, dst).unwrap();
34+
}
35+
2636
// ---------------------------------------------------------------------------
2737
// Path traversal attacks
2838
// ---------------------------------------------------------------------------
@@ -117,6 +127,47 @@ async fn test_no_base_dir_requires_absolute() {
117127
);
118128
}
119129

130+
#[tokio::test]
131+
async fn test_path_traversal_symlink_escape_rejected() {
132+
use fetchkit::file_saver::FileSaver;
133+
134+
let base = tempfile::tempdir().unwrap();
135+
let outside = tempfile::tempdir().unwrap();
136+
let saver = saver_in(base.path());
137+
138+
symlink_dir(outside.path(), &base.path().join("pivot"));
139+
140+
let result = saver.save("pivot/escape.txt", b"pwned").await;
141+
assert!(result.is_err(), "symlink escape should be rejected");
142+
assert!(!outside.path().join("escape.txt").exists());
143+
}
144+
145+
#[tokio::test]
146+
async fn test_execute_with_saver_rejects_symlink_escape() {
147+
let base = tempfile::tempdir().unwrap();
148+
let outside = tempfile::tempdir().unwrap();
149+
let saver = saver_in(base.path());
150+
let tool = tool_with_save();
151+
152+
symlink_dir(outside.path(), &base.path().join("pivot"));
153+
154+
let mock = MockServer::start().await;
155+
Mock::given(method("GET"))
156+
.and(path("/"))
157+
.respond_with(ResponseTemplate::new(200).set_body_string("pwned"))
158+
.mount(&mock)
159+
.await;
160+
161+
let req = FetchRequest::new(format!("{}/", mock.uri())).save_to_file("pivot/escape.txt");
162+
let result = tool.execute_with_saver(req, Some(&saver)).await;
163+
164+
assert!(
165+
result.is_err(),
166+
"execute_with_saver should reject symlink escape"
167+
);
168+
assert!(!outside.path().join("escape.txt").exists());
169+
}
170+
120171
// ---------------------------------------------------------------------------
121172
// Server errors and edge cases
122173
// ---------------------------------------------------------------------------

specs/threat-model.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ expected outbound path.
218218
| TM-INPUT-005 | URL with fragment/query manipulation | Low | Fragments and queries are part of the URL; no special handling needed | **BY DESIGN** |
219219
| TM-INPUT-006 | Prefix bypass via URL authority (http://evil.com@127.0.0.1) | Medium | `url` crate parses authority correctly; resolve-then-check validates the actual host | MITIGATED |
220220
| TM-INPUT-007 | Block prefix matching is string-based, not URL-aware | Medium | URL-aware prefix matching compares parsed components (scheme, host, path) | MITIGATED |
221-
| TM-INPUT-008 | Symlink-based path traversal in LocalFileSaver | Medium | Lexical path normalization; symlinks within base_dir can escape | **ACCEPTED** |
221+
| TM-INPUT-008 | Symlink-based path traversal in LocalFileSaver | Medium | Save-time parent-directory walk rejects symlinks and re-checks canonical path under base_dir | MITIGATED |
222222
| TM-INPUT-009 | LocalFileSaver without base_dir allows arbitrary writes | Medium | Documented limitation; callers should always set base_dir in untrusted contexts | **ACCEPTED** |
223223

224224
### Mitigation Details
@@ -249,13 +249,13 @@ then compares scheme, host (exact match), port, and path (segment-boundary
249249
matching). `http://internal.example.com` correctly does NOT match
250250
`http://internal.example.com.evil.com` since hosts differ after parsing.
251251

252-
**TM-INPUT-008 — Symlink-based path traversal (ACCEPTED):**
253-
`LocalFileSaver` uses lexical normalization (not `canonicalize()`) to prevent `..`
254-
traversal. If a symlink exists within `base_dir` pointing outside it, the lexical
255-
check is bypassed. Accepted because:
256-
- The base_dir is operator-controlled, not user-controlled
257-
- In containerized deployments, the save directory is freshly created per session
258-
- Adding `canonicalize()` introduces TOCTOU races and requires the path to exist
252+
**TM-INPUT-008 — Symlink-based path traversal (MITIGATED):**
253+
`LocalFileSaver` still performs lexical normalization to block `..` traversal, but
254+
save-time enforcement now walks each parent directory component under `base_dir`,
255+
rejects symlinks, canonicalizes each directory after creation/use, and verifies
256+
the canonical path stays under the canonical base directory. `execute_with_saver()`
257+
no longer performs a separate `validate_path()` preflight, so path checks now
258+
happen at write time instead of in a validate-then-write split.
259259

260260
**TM-INPUT-009 — No base_dir allows arbitrary writes (ACCEPTED):**
261261
`LocalFileSaver::new(None)` only requires absolute paths, with no directory restriction.
@@ -430,7 +430,7 @@ None — all previously open threats have been mitigated.
430430
| New client per request | TM-NET | No connection pool state leakage |
431431
| Fetcher API URL hardcoding | TM-SSRF | Specialized fetchers (GitHub, Twitter) connect to hardcoded API hosts, not user-controlled URLs; DNS validation applied on initial connect |
432432
| Proxy env isolation | TM-NET | `reqwest::ClientBuilder::no_proxy()` by default |
433-
| Path traversal prevention | TM-INPUT | Lexical path normalization in `LocalFileSaver` |
433+
| Path traversal prevention | TM-INPUT | Lexical normalization plus save-time parent-directory symlink rejection in `LocalFileSaver` |
434434
| Save feature gating | TM-INPUT | `enable_save_to_file` disabled by default; schema gated |
435435
| Bot-auth feature gating | TM-AUTH | `bot-auth` Cargo feature disabled by default; no crypto deps unless opted in |
436436
| Signature nonce + timestamps | TM-AUTH | 32-byte random nonce + created/expires per signature prevents replay |

0 commit comments

Comments
 (0)