8000 fix(common): contain path traversal in prefix-stripped tar extraction by bryan-minimal · Pull Request #651 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 173 additions & 3 deletions crates/common/src/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ const ZSTD_LEVEL: i32 = 5;

/// Errors which can occur when working with archives.
#[derive(Debug)]
#[non_exhaustive]
pub enum ArchiveError {
IO(std::io::Error),
StripPrefix(StripPrefixError),
CompressionError(String),
/// A tar entry (or a symlink/hardlink target within it) resolved outside
/// the destination directory. Carries the offending archive-relative path.
PathTraversal(PathBuf),
}

impl fmt::Display for ArchiveError {
Expand All @@ -21,6 +25,13 @@ impl fmt::Display for ArchiveError {
ArchiveError::IO(e) => write!(f, "I/O error: {}", e),
ArchiveError::StripPrefix(e) => write!(f, "strip prefix error: {}", e),
ArchiveError::CompressionError(s) => write!(f, "compression error: {}", s),
ArchiveError::PathTraversal(p) => {
write!(
f,
"tar entry {} escapes the destination directory",
p.display()
)
}
}
}
}
Expand All @@ -31,6 +42,7 @@ impl std::error::Error for ArchiveError {
ArchiveError::IO(e) => Some(e),
ArchiveError::StripPrefix(e) => Some(e),
ArchiveError::CompressionError(_) => None,
ArchiveError::PathTraversal(_) => None,
}
}
}
Expand Down Expand Up @@ -191,6 +203,31 @@ pub fn extract_compressed_tar<R: Read>(
}
}

/// Lexically resolves `.` and `..` in a relative path without touching the
/// filesystem, returning `None` if the path is absolute or escapes its own
/// root (a `..` that would climb above the starting directory).
///
/// This is the containment gate for prefix-stripped tar extraction: unlike
/// [`Path::join`], it refuses entries and link targets that would resolve
/// outside `dest_dir`.
fn normalize_within_root(path: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::Prefix(_) | Component::RootDir => return None,
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
return None;
}
}
Component::Normal(c) => out.push(c),
}
}
Some(out)
}

/// Extracts the tarball encoded in the given reader to the given directory,
/// stripping the given prefix.
fn extract_tar_impl<R: Read>(
Expand All @@ -207,11 +244,38 @@ fn extract_tar_impl<R: Read>(
if p.as_ref().as_os_str() == "pax_global_header" {
continue;
}
let path = p.strip_prefix(prefix)?.to_owned();
let stripped = p.strip_prefix(prefix)?.to_owned();

// `Path::join` lets an entry named `../../x` (or an absolute path)
// escape `dest_dir`, and per-entry `Entry::unpack` — unlike the
// whole-archive `Archive::unpack` in the else branch — does not
// contain the write. Reject any entry that resolves outside the
// destination tree before touching the filesystem.
let safe_path = normalize_within_root(&stripped)
.ok_or_else(|| ArchiveError::PathTraversal(stripped.clone()))?;

// A symlink/hardlink whose target escapes the tree would let a
// subsequent entry be written through it to an arbitrary location.
// Validate the link target stays within `dest_dir` too: a symlink
// target is relative to the link's own directory, a hardlink
// target to the destination root.
let entry_type = entry.header().entry_type();
if (entry_type.is_symlink() || entry_type.is_hard_link())
&& let Some(target) = entry.link_name()?
{
let base = if entry_type.is_symlink() {
safe_path.parent().unwrap_or_else(|| Path::new(""))
} else {
Path::new("")
};
if normalize_within_root(&base.join(target.as_ref())).is_none() {
return Err(ArchiveError::PathTraversal(stripped));
}
}

// Ensure any containing directory exists - most tarballs are good about ordering directory entries
// ahead of files within them, but not all.
if let Some(dirs) = path.parent()
if let Some(dirs) = safe_path.parent()
&& !dirs.as_os_str().is_empty()
{
let dir_path = dest_dir.join(dirs);
Expand All @@ -220,7 +284,7 @@ fn extract_tar_impl<R: Read>(
}
}

if let tar::Unpacked::File(f) = entry.unpack(dest_dir.join(&path))? {
if let tar::Unpacked::File(f) = entry.unpack(dest_dir.join(&safe_path))? {
f.sync_all()?;
drop(f);
};
Expand Down Expand Up @@ -348,6 +412,112 @@ mod tests {
Ok(())
}

#[test]
fn extract_rejects_parent_dir_traversal() {
let extract_dir = tempfile::tempdir().unwrap();

// A malicious upstream tarball whose entry escapes the strip prefix.
// The tar *writer* refuses a `..` path via `set_path`, so write the
// header name field directly — exactly what a hand-crafted archive
// (e.g. GNU tar `--absolute-names`) produces.
let mut tar_data = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_data);
let content = b"pwned";
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_entry_type(tar::EntryType::Regular);
header.set_mode(0o644);
let name = b"prefix/../../evil.txt";
header.as_gnu_mut().unwrap().name[..name.len()].copy_from_slice(name);
header.set_cksum();
builder.append(&header, &content[..]).unwrap();
builder.finish().unwrap();
}

let err = extract_compressed_tar(
&tar_data[..],
Compression::None,
extract_dir.path(),
Some(&"prefix".to_string()),
)
.unwrap_err();
assert!(
matches!(err, ArchiveError::PathTraversal(_)),
"expected PathTraversal, got {err:?}"
);
// Nothing was written outside the destination.
assert!(
!extract_dir
.path()
.parent()
.unwrap()
.join("evil.txt")
.exists()
);
}

#[test]
fn extract_rejects_escaping_symlink() {
let extract_dir = tempfile::tempdir().unwrap();

// A symlink entry pointing outside the tree: even though the link's own
// path is in-tree, a later entry could be written through it.
let mut tar_data = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_data);
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_path("prefix/link").unwrap();
header.set_link_name("../../../etc").unwrap();
header.set_size(0);
header.set_cksum();
builder.append(&header, &[][..]).unwrap();
builder.finish().unwrap();
}

let err = extract_compressed_tar(
&tar_data[..],
Compression::None,
extract_dir.path(),
Some(&"prefix".to_string()),
)
.unwrap_err();
assert!(
matches!(err, ArchiveError::PathTraversal(_)),
"expected PathTraversal, got {err:?}"
);
}

#[test]
fn extract_allows_in_tree_symlink() -> Result<(), ArchiveError> {
let extract_dir = tempfile::tempdir().unwrap();

// A relative symlink that stays within the extracted tree is fine.
let mut tar_data = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_data);
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_path("prefix/sub/link").unwrap();
header.set_link_name("../target").unwrap();
header.set_size(0);
header.set_cksum();
builder.append(&header, &[][..]).unwrap();
builder.finish().unwrap();
}

extract_compressed_tar(
&tar_data[..],
Compression::None,
extract_dir.path(),
Some(&"prefix".to_string()),
)?;

assert!(extract_dir.path().join("sub/link").is_symlink());
Ok(())
}

#[test]
fn extract_gzip_tar() -> Result<(), ArchiveError> {
let extract_dir = tempfile::tempdir().unwrap();
Expand Down
0