fluidattacks-core 0.19.0

Fluid Attacks Core Library
Documentation
use anyhow::{bail, Context, Result};
use flate2::read::GzDecoder;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use tar::Archive;
use tracing::{debug, info};

pub fn extract(data: &[u8], dest_dir: &Path) -> Result<()> {
    let abs_dest = dest_dir.canonicalize().context("resolving dest dir")?;

    let decoder = GzDecoder::new(data);
    let mut archive = Archive::new(decoder);

    let mut entry_count = 0u64;
    for entry_result in archive.entries().context("reading tar entries")? {
        let mut entry = entry_result.context("reading tar entry")?;
        entry_count = entry_count.saturating_add(1);

        let path = entry.path().context("reading entry path")?.into_owned();

        if entry_count <= 10 {
            info!(
                name = %path.display(),
                kind = entry.header().entry_type().as_byte(),
                size = entry.header().size().unwrap_or(0),
                "tar entry"
            );
        }

        match validate_entry(&path, &entry, &abs_dest)? {
            EntryAction::Skip => {
                debug!(name = %path.display(), "skipping tar entry");
                continue;
            }
            EntryAction::Process => {}
        }

        let target = abs_dest.join(clean_path(&path));
        write_entry(&mut entry, &target)?;
    }

    info!(total_entries = entry_count, "tar extraction complete");
    Ok(())
}

fn write_entry(entry: &mut tar::Entry<impl Read>, target: &Path) -> Result<()> {
    match entry.header().entry_type() {
        tar::EntryType::Directory => {
            fs::create_dir_all(target)
                .with_context(|| format!("creating directory {}", target.display()))?;
        }
        tar::EntryType::Regular => {
            if let Some(parent) = target.parent() {
                fs::create_dir_all(parent)
                    .with_context(|| format!("creating parent dir for {}", target.display()))?;
            }
            let mode = entry.header().mode().unwrap_or(0o644);
            let mut contents = Vec::new();
            entry
                .read_to_end(&mut contents)
                .with_context(|| format!("reading file {}", target.display()))?;
            fs::write(target, &contents)
                .with_context(|| format!("writing file {}", target.display()))?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                fs::set_permissions(target, fs::Permissions::from_mode(mode)).ok();
            }
        }
        _ => {}
    }
    Ok(())
}

fn clean_path(path: &Path) -> PathBuf {
    let mut clean = PathBuf::new();
    for component in path.components() {
        if let std::path::Component::Normal(c) = component {
            clean.push(c);
        }
    }
    clean
}

enum EntryAction {
    Process,
    Skip,
}

fn validate_entry(
    path: &Path,
    entry: &tar::Entry<impl Read>,
    dest_dir: &Path,
) -> Result<EntryAction> {
    let path_str = path.to_string_lossy();

    if path.is_absolute() {
        bail!("tar contains absolute path: {path_str}");
    }

    if path_str.contains("..") {
        bail!("tar contains path traversal: {path_str}");
    }

    let target = dest_dir.join(clean_path(path));
    if !target.starts_with(dest_dir) {
        bail!("tar entry escapes destination: {path_str}");
    }

    let entry_type = entry.header().entry_type();
    if entry_type == tar::EntryType::Symlink || entry_type == tar::EntryType::Link {
        return Ok(EntryAction::Skip);
    }

    Ok(EntryAction::Process)
}