Skip to main content

fluidattacks_core_git/
tar.rs

1use async_compression::tokio::bufread::GzipDecoder;
2use std::future::poll_fn;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5use std::pin::Pin;
6use std::time::{Duration, SystemTime};
7use tokio::io::{AsyncRead, AsyncWriteExt, BufReader};
8use tokio_tar::{Archive, Entry, EntryType};
9use tracing::{debug, error, info};
10
11use crate::errors::ExtractError;
12
13/// Extracts the gzipped tar read from `reader` into `dest_dir`, which must
14/// exist and hold no symlink.
15///
16/// Links, absolute names and names with a `..` component are skipped; every
17/// other member is written with no setuid, setgid, sticky or write for
18/// others, as python's `data` filter does. The whole stream is read, so a
19/// checksum that does not match fails the extraction.
20pub async fn extract<R: AsyncRead + Unpin + Send>(
21    reader: R,
22    dest_dir: &Path,
23) -> Result<(), ExtractError> {
24    let mut decoder = GzipDecoder::new(BufReader::new(reader));
25    decoder.multiple_members(true);
26    let mut archive = Archive::new(decoder);
27    let mut entries = archive.entries().map_err(unreadable)?;
28
29    let mut entry_count = 0u64;
30    while let Some(entry) =
31        poll_fn(|cx| futures_core::Stream::poll_next(Pin::new(&mut entries), cx)).await
32    {
33        let mut entry = entry.map_err(unreadable)?;
34        entry_count = entry_count.saturating_add(1);
35        let path = entry.path().map_err(unreadable)?.into_owned();
36
37        if entry_count <= 10 {
38            info!(
39                name = %path.display(),
40                kind = entry.header().entry_type().as_byte(),
41                size = entry.effective_size(),
42                "tar entry"
43            );
44        }
45        if !is_safe(&path, entry.header().entry_type()) {
46            debug!(name = %path.display(), "skipping tar entry");
47            continue;
48        }
49
50        write_entry(&mut entry, &dest_dir.join(clean_path(&path))).await?;
51    }
52    drop(entries);
53
54    // The gzip trailer, and with it the checksum, comes after the last member.
55    tokio::io::copy(&mut archive, &mut tokio::io::sink())
56        .await
57        .map_err(unreadable)?;
58
59    info!(total_entries = entry_count, "tar extraction complete");
60    Ok(())
61}
62
63fn unreadable(error: io::Error) -> ExtractError {
64    if error.kind() == io::ErrorKind::UnexpectedEof {
65        ExtractError::Truncated(error)
66    } else {
67        ExtractError::Corrupt(error)
68    }
69}
70
71async fn write_entry<R: AsyncRead + Unpin>(
72    entry: &mut Entry<Archive<R>>,
73    target: &Path,
74) -> Result<(), ExtractError> {
75    match entry.header().entry_type() {
76        EntryType::Directory => tokio::fs::create_dir_all(target)
77            .await
78            .map_err(ExtractError::Io),
79        EntryType::Regular | EntryType::Continuous => write_file(entry, target).await,
80        _ => Ok(()),
81    }
82}
83
84async fn write_file<R: AsyncRead + Unpin>(
85    entry: &mut Entry<Archive<R>>,
86    target: &Path,
87) -> Result<(), ExtractError> {
88    if let Some(parent) = target.parent() {
89        tokio::fs::create_dir_all(parent)
90            .await
91            .map_err(ExtractError::Io)?;
92    }
93
94    // As python's `data` filter: no setuid, setgid, sticky or write for others.
95    let mode = (entry.header().mode().unwrap_or(0o644) | 0o600) & 0o755;
96    let mtime = entry.header().mtime().ok();
97    let mut file = tokio::fs::File::create(target)
98        .await
99        .map_err(ExtractError::Io)?;
100    copy_member(entry, &mut file).await?;
101    file.flush().await.map_err(ExtractError::Io)?;
102
103    let file = file.into_std().await;
104    if let Some(modified) =
105        mtime.and_then(|secs| SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs)))
106    {
107        file.set_modified(modified).ok();
108    }
109    drop(file);
110
111    #[cfg(unix)]
112    {
113        use std::os::unix::fs::PermissionsExt;
114        tokio::fs::set_permissions(target, std::fs::Permissions::from_mode(mode))
115            .await
116            .ok();
117    }
118    Ok(())
119}
120
121// Reading the member is reading the archive, writing it is writing the disk,
122// and each fails as its own kind.
123async fn copy_member<R: AsyncRead + Unpin>(
124    entry: &mut Entry<Archive<R>>,
125    file: &mut tokio::fs::File,
126) -> Result<(), ExtractError> {
127    use tokio::io::AsyncReadExt;
128    let mut buffer = vec![0u8; 64 * 1024];
129    loop {
130        let read = entry.read(&mut buffer).await.map_err(unreadable)?;
131        if read == 0 {
132            return Ok(());
133        }
134        let chunk = buffer.get(..read).unwrap_or_default();
135        file.write_all(chunk).await.map_err(ExtractError::Io)?;
136    }
137}
138
139fn clean_path(path: &Path) -> PathBuf {
140    path.components()
141        .filter_map(|component| match component {
142            Component::Normal(part) => Some(part),
143            _ => None,
144        })
145        .collect()
146}
147
148fn is_safe(path: &Path, kind: EntryType) -> bool {
149    if kind == EntryType::Symlink || kind == EntryType::Link {
150        return false;
151    }
152    if path.is_absolute()
153        || path
154            .components()
155            .any(|component| matches!(component, Component::ParentDir))
156    {
157        error!(name = %path.display(), "unsafe path detected");
158        return false;
159    }
160    true
161}
162
163#[cfg(test)]
164mod tests {
165    use super::extract;
166    use crate::errors::ExtractError;
167    use flate2::write::GzEncoder;
168    use flate2::Compression;
169    use std::path::Path;
170    use std::time::{Duration, SystemTime};
171
172    const MTIME: u64 = 1_700_000_000;
173
174    fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
175        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
176        builder.preserve_absolute(true);
177        for (name, contents) in entries {
178            let mut header = tar::Header::new_gnu();
179            header.set_size(u64::try_from(contents.len()).unwrap());
180            header.set_mode(0o644);
181            header.set_mtime(MTIME);
182            builder.append_data(&mut header, name, *contents).unwrap();
183        }
184        builder.into_inner().unwrap().finish().unwrap()
185    }
186
187    // The tar crate refuses to write a `..` component, so the name goes into
188    // the header by hand, as a hostile archiver would put it.
189    fn hostile_tar_gz(kind: tar::EntryType, name: &str, link: &str, contents: &[u8]) -> Vec<u8> {
190        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
191        let mut header = tar::Header::new_old();
192        header
193            .as_old_mut()
194            .name
195            .get_mut(..name.len())
196            .unwrap()
197            .copy_from_slice(name.as_bytes());
198        header
199            .as_old_mut()
200            .linkname
201            .get_mut(..link.len())
202            .unwrap()
203            .copy_from_slice(link.as_bytes());
204        header.set_entry_type(kind);
205        header.set_size(u64::try_from(contents.len()).unwrap());
206        header.set_mode(0o644);
207        header.set_cksum();
208        builder.append(&header, contents).unwrap();
209        builder.into_inner().unwrap().finish().unwrap()
210    }
211
212    async fn extract_into(archive: &[u8], dest: &Path) {
213        extract(archive, dest).await.unwrap();
214    }
215
216    async fn extract_to_temp(archive: &[u8]) -> tempfile::TempDir {
217        let dir = tempfile::tempdir().unwrap();
218        extract_into(archive, dir.path()).await;
219        dir
220    }
221
222    fn entries_under(dir: &Path) -> Vec<String> {
223        let mut found: Vec<String> = walkdir::WalkDir::new(dir)
224            .min_depth(1)
225            .into_iter()
226            .map(|entry| {
227                entry
228                    .unwrap()
229                    .path()
230                    .strip_prefix(dir)
231                    .unwrap()
232                    .display()
233                    .to_string()
234            })
235            .collect();
236        found.sort();
237        found
238    }
239
240    #[tokio::test]
241    async fn an_archive_is_extracted_from_a_reader() {
242        let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")])).await;
243
244        assert_eq!(
245            std::fs::read_to_string(dir.path().join("repo/HEAD")).unwrap(),
246            "ref: refs/heads/trunk\n"
247        );
248    }
249
250    #[tokio::test]
251    async fn an_absolute_entry_is_skipped_and_the_rest_is_extracted() {
252        let dir = extract_to_temp(&tar_gz(&[
253            ("/etc/passwd", b"root::0:0:::\n"),
254            ("repo/HEAD", b"ref: refs/heads/trunk\n"),
255        ]))
256        .await;
257
258        assert_eq!(entries_under(dir.path()), ["repo", "repo/HEAD"]);
259    }
260
261    #[tokio::test]
262    async fn a_name_holding_two_dots_is_not_a_traversal() {
263        let dir = extract_to_temp(&tar_gz(&[("repo/v1../a..b", b"kept\n")])).await;
264
265        assert_eq!(
266            std::fs::read_to_string(dir.path().join("repo/v1../a..b")).unwrap(),
267            "kept\n"
268        );
269    }
270
271    #[tokio::test]
272    async fn an_entry_that_climbs_out_writes_nothing_anywhere() {
273        for name in ["../x", "repo/../../x", "repo/.."] {
274            let root = tempfile::tempdir().unwrap();
275            let dest = root.path().join("a/b");
276            std::fs::create_dir_all(&dest).unwrap();
277
278            extract_into(
279                &hostile_tar_gz(tar::EntryType::Regular, name, "", b"escaped\n"),
280                &dest,
281            )
282            .await;
283
284            assert_eq!(entries_under(root.path()), ["a", "a/b"], "{name}");
285        }
286    }
287
288    #[tokio::test]
289    async fn a_link_entry_writes_nothing_anywhere() {
290        let outside = tempfile::tempdir().unwrap();
291        let target = outside.path().join("target");
292        std::fs::write(&target, b"outside\n").unwrap();
293        let target = target.to_string_lossy();
294
295        for kind in [tar::EntryType::Symlink, tar::EntryType::Link] {
296            let dest = tempfile::tempdir().unwrap();
297
298            extract_into(
299                &hostile_tar_gz(kind, "repo/link", &target, b""),
300                dest.path(),
301            )
302            .await;
303
304            assert!(entries_under(dest.path()).is_empty(), "{kind:?}");
305            assert_eq!(
306                std::fs::read_to_string(outside.path().join("target")).unwrap(),
307                "outside\n"
308            );
309        }
310    }
311
312    #[tokio::test]
313    async fn a_checksum_that_does_not_match_is_a_corrupt_archive() {
314        let mut archive = tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]);
315        // The gzip trailer ends with the CRC32 and the length of the data.
316        let crc = archive.len() - 8;
317        for byte in archive.get_mut(crc..crc + 4).unwrap() {
318            *byte ^= 0xFF;
319        }
320        let dir = tempfile::tempdir().unwrap();
321
322        let outcome = extract(archive.as_slice(), dir.path()).await;
323
324        assert!(
325            matches!(outcome, Err(ExtractError::Corrupt(_))),
326            "{outcome:?}"
327        );
328    }
329
330    #[tokio::test]
331    async fn a_stream_cut_before_its_trailer_is_a_truncated_archive() {
332        let archive = tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]);
333        let dir = tempfile::tempdir().unwrap();
334
335        let outcome = extract(archive.get(..archive.len() - 4).unwrap(), dir.path()).await;
336
337        assert!(
338            matches!(outcome, Err(ExtractError::Truncated(_))),
339            "{outcome:?}"
340        );
341    }
342
343    #[cfg(unix)]
344    #[tokio::test]
345    async fn an_entry_keeps_no_setuid_bit_nor_write_for_others() {
346        use std::os::unix::fs::PermissionsExt;
347        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
348        let mut header = tar::Header::new_gnu();
349        header.set_size(0);
350        header.set_mode(0o4777);
351        builder
352            .append_data(&mut header, "repo/run", std::io::empty())
353            .unwrap();
354        let dir = extract_to_temp(&builder.into_inner().unwrap().finish().unwrap()).await;
355
356        let mode = std::fs::metadata(dir.path().join("repo/run"))
357            .unwrap()
358            .permissions()
359            .mode();
360        assert_eq!(mode & 0o7777, 0o755);
361    }
362
363    #[tokio::test]
364    async fn the_entry_mtime_is_preserved() {
365        let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")])).await;
366
367        let modified = std::fs::metadata(dir.path().join("repo/HEAD"))
368            .unwrap()
369            .modified()
370            .unwrap();
371        assert_eq!(
372            modified,
373            SystemTime::UNIX_EPOCH
374                .checked_add(Duration::from_secs(MTIME))
375                .unwrap()
376        );
377    }
378}