Skip to main content

fluidattacks_core_git/
tar.rs

1use anyhow::{Context, Result};
2use flate2::read::GzDecoder;
3use std::fs;
4use std::io::Read;
5use std::path::{Component, Path, PathBuf};
6use std::time::{Duration, SystemTime};
7use tar::Archive;
8use tracing::{debug, error, info};
9
10pub fn extract<R: Read>(reader: R, dest_dir: &Path) -> Result<()> {
11    let abs_dest = dest_dir.canonicalize().context("resolving dest dir")?;
12
13    let decoder = GzDecoder::new(reader);
14    let mut archive = Archive::new(decoder);
15
16    let mut entry_count = 0u64;
17    for entry_result in archive.entries().context("reading tar entries")? {
18        let mut entry = entry_result.context("reading tar entry")?;
19        entry_count = entry_count.saturating_add(1);
20
21        let path = entry.path().context("reading entry path")?.into_owned();
22
23        if entry_count <= 10 {
24            info!(
25                name = %path.display(),
26                kind = entry.header().entry_type().as_byte(),
27                size = entry.header().size().unwrap_or(0),
28                "tar entry"
29            );
30        }
31
32        match entry_action(&path, &entry, &abs_dest) {
33            EntryAction::Skip => {
34                debug!(name = %path.display(), "skipping tar entry");
35                continue;
36            }
37            EntryAction::Process => {}
38        }
39
40        let target = abs_dest.join(clean_path(&path));
41        write_entry(&mut entry, &target)?;
42    }
43
44    info!(total_entries = entry_count, "tar extraction complete");
45    Ok(())
46}
47
48fn write_entry(entry: &mut tar::Entry<impl Read>, target: &Path) -> Result<()> {
49    match entry.header().entry_type() {
50        tar::EntryType::Directory => {
51            fs::create_dir_all(target)
52                .with_context(|| format!("creating directory {}", target.display()))?;
53        }
54        tar::EntryType::Regular | tar::EntryType::Continuous => write_file(entry, target)?,
55        _ => {}
56    }
57    Ok(())
58}
59
60fn write_file(entry: &mut tar::Entry<impl Read>, target: &Path) -> Result<()> {
61    if let Some(parent) = target.parent() {
62        fs::create_dir_all(parent)
63            .with_context(|| format!("creating parent dir for {}", target.display()))?;
64    }
65
66    // As python's `data` filter: no setuid, setgid, sticky or write for others.
67    let mode = (entry.header().mode().unwrap_or(0o644) | 0o600) & 0o755;
68    let mtime = entry.header().mtime().ok();
69    let mut file =
70        fs::File::create(target).with_context(|| format!("creating file {}", target.display()))?;
71    std::io::copy(entry, &mut file)
72        .with_context(|| format!("writing file {}", target.display()))?;
73
74    if let Some(modified) =
75        mtime.and_then(|secs| SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs)))
76    {
77        file.set_modified(modified).ok();
78    }
79    drop(file);
80
81    #[cfg(unix)]
82    {
83        use std::os::unix::fs::PermissionsExt;
84        fs::set_permissions(target, fs::Permissions::from_mode(mode)).ok();
85    }
86    Ok(())
87}
88
89fn clean_path(path: &Path) -> PathBuf {
90    let mut clean = PathBuf::new();
91    for component in path.components() {
92        if let Component::Normal(c) = component {
93            clean.push(c);
94        }
95    }
96    clean
97}
98
99enum EntryAction {
100    Process,
101    Skip,
102}
103
104fn entry_action(path: &Path, entry: &tar::Entry<impl Read>, dest_dir: &Path) -> EntryAction {
105    let entry_type = entry.header().entry_type();
106    if entry_type == tar::EntryType::Symlink || entry_type == tar::EntryType::Link {
107        return EntryAction::Skip;
108    }
109
110    let target = dest_dir.join(clean_path(path));
111    if path.is_absolute()
112        || path
113            .components()
114            .any(|component| matches!(component, Component::ParentDir))
115        || !target.starts_with(dest_dir)
116    {
117        error!(name = %path.display(), "unsafe path detected");
118        return EntryAction::Skip;
119    }
120
121    EntryAction::Process
122}
123
124#[cfg(test)]
125mod tests {
126    use super::extract;
127    use flate2::write::GzEncoder;
128    use flate2::Compression;
129    use std::io::BufReader;
130    use std::time::{Duration, SystemTime};
131
132    const MTIME: u64 = 1_700_000_000;
133
134    fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
135        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
136        builder.preserve_absolute(true);
137        for (name, contents) in entries {
138            let mut header = tar::Header::new_gnu();
139            header.set_size(u64::try_from(contents.len()).unwrap());
140            header.set_mode(0o644);
141            header.set_mtime(MTIME);
142            builder.append_data(&mut header, name, *contents).unwrap();
143        }
144        builder.into_inner().unwrap().finish().unwrap()
145    }
146
147    fn extract_to_temp(archive: &[u8]) -> tempfile::TempDir {
148        let dir = tempfile::tempdir().unwrap();
149        let path = dir.path().join("repo.tar.gz");
150        std::fs::write(&path, archive).unwrap();
151
152        extract(
153            BufReader::new(std::fs::File::open(&path).unwrap()),
154            dir.path(),
155        )
156        .unwrap();
157
158        dir
159    }
160
161    #[test]
162    fn an_archive_is_extracted_from_a_reader_over_a_file() {
163        let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));
164
165        assert_eq!(
166            std::fs::read_to_string(dir.path().join("repo/HEAD")).unwrap(),
167            "ref: refs/heads/trunk\n"
168        );
169    }
170
171    #[test]
172    fn an_absolute_entry_is_skipped_and_the_rest_is_extracted() {
173        let dir = extract_to_temp(&tar_gz(&[
174            ("/etc/passwd", b"root::0:0:::\n"),
175            ("repo/HEAD", b"ref: refs/heads/trunk\n"),
176        ]));
177
178        assert!(!dir.path().join("etc/passwd").exists());
179        assert!(dir.path().join("repo/HEAD").exists());
180    }
181
182    #[test]
183    fn a_name_holding_two_dots_is_not_a_traversal() {
184        let dir = extract_to_temp(&tar_gz(&[("repo/a..b", b"kept\n")]));
185
186        assert_eq!(
187            std::fs::read_to_string(dir.path().join("repo/a..b")).unwrap(),
188            "kept\n"
189        );
190    }
191
192    #[cfg(unix)]
193    #[test]
194    fn an_entry_keeps_no_setuid_bit_nor_write_for_others() {
195        use std::os::unix::fs::PermissionsExt;
196        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
197        let mut header = tar::Header::new_gnu();
198        header.set_size(0);
199        header.set_mode(0o4777);
200        builder
201            .append_data(&mut header, "repo/run", std::io::empty())
202            .unwrap();
203        let dir = extract_to_temp(&builder.into_inner().unwrap().finish().unwrap());
204
205        let mode = std::fs::metadata(dir.path().join("repo/run"))
206            .unwrap()
207            .permissions()
208            .mode();
209        assert_eq!(mode & 0o7777, 0o755);
210    }
211
212    #[test]
213    fn the_entry_mtime_is_preserved() {
214        let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));
215
216        let modified = std::fs::metadata(dir.path().join("repo/HEAD"))
217            .unwrap()
218            .modified()
219            .unwrap();
220        assert_eq!(
221            modified,
222            SystemTime::UNIX_EPOCH
223                .checked_add(Duration::from_secs(MTIME))
224                .unwrap()
225        );
226    }
227}