Skip to content

Commit 9008c31

Browse files
authored
Merge pull request #378 from dallay/harden-archive-extraction-5369409038445910297
fix(security): harden archive extraction against path traversal
2 parents 674b6d0 + 15721c3 commit 9008c31

3 files changed

Lines changed: 158 additions & 21 deletions

File tree

.agents/journal/sentinel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ tool that are known to contain user-provided credentials or sensitive environmen
2626
**Vulnerability:** The `copy_dir_recursively` function used during local skill installation followed symbolic links. A malicious skill source could include a symlink to a sensitive file (e.g., `~/.ssh/id_rsa`), causing its content to be copied into the project.
2727
**Learning:** Rust's `std::fs::copy` follows symbolic links by default, copying the target's content rather than the link itself. Similarly, `DirEntry::metadata()` follows links, while `DirEntry::file_type()` does not.
2828
**Prevention:** Explicitly check `file_type.is_symlink()` and skip or handle symlinks appropriately when performing recursive copies of untrusted directory structures.
29+
30+
## 2025-05-18 - Cross-Platform Absolute Path Validation in Archives
31+
**Vulnerability:** String-based path checks (like `starts_with('/')`) or platform-specific `Path::is_absolute()` calls failed to detect Windows-style absolute paths (e.g., `C:/...`) when running on Unix systems, leading to potential path traversal vulnerabilities in skill archives.
32+
**Learning:** Rust's `std::path::Path` behavior depends on the host operating system. On Unix, a path starting with a drive letter is considered relative, which allows it to pass simple `is_absolute()` checks and be joined to a base directory, potentially escaping it if not carefully validated.
33+
**Prevention:** Use `Path::components()` to explicitly reject `RootDir`, `Prefix`, and `ParentDir` components. For cross-platform safety on Unix, also manually check for Windows drive letter patterns (e.g., `filename.as_bytes()[1] == b':'`) in untrusted archive entry paths.

Cargo.lock

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

src/skills/install.rs

Lines changed: 144 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ pub enum SkillInstallError {
3131
}
3232

3333
// Module-level helper to recursively copy directories
34+
fn archive_path_is_unsafe(path: &str) -> bool {
35+
Path::new(path).components().any(|component| {
36+
matches!(
37+
component,
38+
std::path::Component::ParentDir
39+
| std::path::Component::RootDir
40+
| std::path::Component::Prefix(_)
41+
)
42+
}) || path
43+
.as_bytes()
44+
.get(1)
45+
.is_some_and(|second_byte| *second_byte == b':')
46+
|| path.starts_with('\\')
47+
}
48+
3449
fn copy_dir_recursively(
3550
src: &std::path::Path,
3651
dst: &std::path::Path,
@@ -131,7 +146,8 @@ pub fn install_from_zip(
131146
for i in 0..zip.len() {
132147
let mut file = zip.by_index(i).map_err(SkillInstallError::ZipArchive)?;
133148
let filename = file.name();
134-
if filename.starts_with('/') || filename.contains("..") {
149+
// SECURITY: Reject absolute paths, drive prefixes, and path traversal attempts.
150+
if archive_path_is_unsafe(filename) {
135151
return Err(SkillInstallError::PathTraversal(filename.to_string()));
136152
}
137153
let outpath = tmp.path().join(filename);
@@ -319,8 +335,8 @@ pub async fn fetch_and_unpack_to_tempdir(url: &str) -> Result<TempDir, SkillInst
319335
let mut file = zip.by_index(i).map_err(SkillInstallError::ZipArchive)?;
320336
let full_name = file.name();
321337

322-
// Reject absolute paths and path traversal attempts
323-
if full_name.starts_with('/') || full_name.contains("..") {
338+
// SECURITY: Reject absolute paths, drive prefixes, and path traversal attempts.
339+
if archive_path_is_unsafe(full_name) {
324340
return Err(SkillInstallError::PathTraversal(full_name.to_string()));
325341
}
326342

@@ -406,18 +422,20 @@ pub async fn fetch_and_unpack_to_tempdir(url: &str) -> Result<TempDir, SkillInst
406422

407423
for entry in entries {
408424
let mut entry = entry.map_err(SkillInstallError::Io)?;
425+
426+
// SECURITY: Skip symlinks, hardlinks, and other special files to prevent
427+
// unexpected side effects or path traversal during unpacking.
428+
let entry_type = entry.header().entry_type();
429+
if !entry_type.is_file() && !entry_type.is_dir() {
430+
continue;
431+
}
432+
409433
let full_path = entry.path().map_err(SkillInstallError::Io)?;
434+
let full_path_string = full_path.to_string_lossy();
410435

411-
if full_path.components().any(|c| {
412-
matches!(
413-
c,
414-
std::path::Component::ParentDir
415-
| std::path::Component::RootDir
416-
| std::path::Component::Prefix(_)
417-
)
418-
}) {
436+
if archive_path_is_unsafe(&full_path_string) {
419437
return Err(SkillInstallError::PathTraversal(
420-
full_path.to_string_lossy().into_owned(),
438+
full_path_string.into_owned(),
421439
));
422440
}
423441

@@ -465,3 +483,117 @@ pub async fn fetch_and_unpack_to_tempdir(url: &str) -> Result<TempDir, SkillInst
465483
}
466484
Ok(tmp)
467485
}
486+
487+
#[cfg(test)]
488+
mod tests {
489+
use super::*;
490+
use std::io::Cursor;
491+
use std::io::Write;
492+
use zip::ZipWriter;
493+
use zip::write::FileOptions;
494+
495+
#[test]
496+
fn test_zip_absolute_path_rejection() {
497+
let mut buf = Vec::new();
498+
{
499+
let mut zip = ZipWriter::new(Cursor::new(&mut buf));
500+
zip.start_file("C:/absolute/path/SKILL.md", FileOptions::<()>::default())
501+
.unwrap();
502+
zip.write_all(b"name: test-skill").unwrap();
503+
zip.finish().unwrap();
504+
}
505+
506+
let temp_root = tempfile::tempdir().unwrap();
507+
let result = install_from_zip("test-skill", Cursor::new(buf), temp_root.path());
508+
509+
match result {
510+
Err(SkillInstallError::PathTraversal(path)) => {
511+
assert!(
512+
path.contains("C:/absolute/path/SKILL.md")
513+
|| path.contains(r"C:\absolute\path\SKILL.md")
514+
);
515+
}
516+
other => panic!("Expected PathTraversal error, got {:?}", other),
517+
}
518+
}
519+
520+
#[test]
521+
fn test_zip_parent_traversal_rejection() {
522+
let mut buf = Vec::new();
523+
{
524+
let mut zip = ZipWriter::new(Cursor::new(&mut buf));
525+
zip.start_file("../outside.md", FileOptions::<()>::default())
526+
.unwrap();
527+
zip.write_all(b"content").unwrap();
528+
zip.finish().unwrap();
529+
}
530+
531+
let temp_root = tempfile::tempdir().unwrap();
532+
let result = install_from_zip("test-skill", Cursor::new(buf), temp_root.path());
533+
534+
match result {
535+
Err(SkillInstallError::PathTraversal(path)) => {
536+
assert!(path.contains("../outside.md"));
537+
}
538+
other => panic!("Expected PathTraversal error, got {:?}", other),
539+
}
540+
}
541+
542+
#[test]
543+
fn test_zip_root_dir_rejection() {
544+
let mut buf = Vec::new();
545+
{
546+
let mut zip = ZipWriter::new(Cursor::new(&mut buf));
547+
zip.start_file("/absolute/path/SKILL.md", FileOptions::<()>::default())
548+
.unwrap();
549+
zip.write_all(b"content").unwrap();
550+
zip.finish().unwrap();
551+
}
552+
553+
let temp_root = tempfile::tempdir().unwrap();
554+
let result = install_from_zip("test-skill", Cursor::new(buf), temp_root.path());
555+
556+
match result {
557+
Err(SkillInstallError::PathTraversal(path)) => {
558+
assert!(path.contains("/absolute/path/SKILL.md"));
559+
}
560+
other => panic!("Expected PathTraversal error, got {:?}", other),
561+
}
562+
}
563+
564+
#[tokio::test]
565+
async fn test_tar_windows_drive_path_rejection() {
566+
let temp_root = tempfile::tempdir().unwrap();
567+
let archive_path = temp_root.path().join("malicious.tar.gz");
568+
{
569+
let archive_file = std::fs::File::create(&archive_path).unwrap();
570+
let encoder =
571+
flate2::write::GzEncoder::new(archive_file, flate2::Compression::default());
572+
let mut builder = tar::Builder::new(encoder);
573+
let content = b"name: test-skill";
574+
let mut header = tar::Header::new_gnu();
575+
header.set_size(content.len() as u64);
576+
header.set_cksum();
577+
578+
// Bypass tar crate's path validation which fails on Windows during archive creation
579+
// by injecting the path bytes directly into the raw header name field
580+
let malicious_path = b"C:/absolute/path/SKILL.md";
581+
let mut name_bytes = [0u8; 100];
582+
name_bytes[..malicious_path.len()].copy_from_slice(malicious_path);
583+
header.as_gnu_mut().unwrap().name = name_bytes;
584+
header.set_cksum(); // Re-calculate checksum after modifying name
585+
586+
builder.append(&header, &content[..]).unwrap();
587+
builder.finish().unwrap();
588+
}
589+
590+
let result = fetch_and_unpack_to_tempdir(archive_path.to_str().unwrap()).await;
591+
592+
match result {
593+
Err(SkillInstallError::PathTraversal(path)) => {
594+
assert!(path.contains("C:/absolute/path/SKILL.md"));
595+
}
596+
other => panic!("Expected PathTraversal error, got {:?}", other),
597+
}
598+
}
599+
}

0 commit comments

Comments
 (0)