Skip to main content

mnemo/
archive.rs

1//! Extraction sûre d'archives `tar` (défense en profondeur).
2//!
3//! La crate `tar` valide déjà les chemins lors de `unpack`, mais mnemo manipule
4//! des archives potentiellement fournies par l'utilisateur (`mnemo restore`) ou
5//! téléchargées (`mnemo upgrade`). On ajoute donc une validation **explicite et
6//! testable** de chaque entrée avant écriture :
7//! - rejet des chemins absolus (`/tmp/evil`) ;
8//! - rejet des remontées de dossier (`../evil`) ;
9//! - rejet des liens (symboliques / physiques) dont la cible sort de la racine.
10//!
11//! Garantie : aucune écriture hors du dossier de destination fourni.
12
13use std::io::Read;
14use std::path::{Component, Path};
15
16use anyhow::{bail, Result};
17
18/// Vérifie qu'un chemin d'entrée d'archive est sûr (fonction pure).
19///
20/// Accepte uniquement des chemins relatifs composés de segments normaux
21/// (`a/b/c`) ou `.`. Rejette tout chemin absolu ou contenant `..`.
22pub fn validate_entry_path(path: &Path) -> Result<()> {
23    for component in path.components() {
24        match component {
25            Component::Normal(_) | Component::CurDir => {}
26            Component::ParentDir => {
27                bail!(
28                    "entrée d'archive rejetée (remontée de dossier `..`) : {}",
29                    path.display()
30                )
31            }
32            Component::RootDir | Component::Prefix(_) => {
33                bail!(
34                    "entrée d'archive rejetée (chemin absolu) : {}",
35                    path.display()
36                )
37            }
38        }
39    }
40    Ok(())
41}
42
43/// Extrait une archive `tar` dans `dest` en validant chaque entrée **avant**
44/// écriture. Le chemin de l'entrée et, le cas échéant, la cible d'un lien sont
45/// contrôlés par [`validate_entry_path`].
46pub fn safe_unpack<R: Read>(mut archive: tar::Archive<R>, dest: &Path) -> Result<()> {
47    for entry in archive.entries()? {
48        let mut entry = entry?;
49        let path = entry.path()?.into_owned();
50        validate_entry_path(&path)?;
51
52        // Les liens (symboliques ou physiques) doivent eux aussi pointer dans
53        // la racine : on valide leur cible.
54        if let Some(link) = entry.link_name()? {
55            validate_entry_path(&link)?;
56        }
57
58        // `unpack_in` revalide de son côté (défense en profondeur) et renvoie
59        // `false` s'il refuse d'écrire hors de `dest`.
60        let unpacked = entry.unpack_in(dest)?;
61        if !unpacked {
62            bail!(
63                "entrée d'archive refusée (hors du dossier cible) : {}",
64                path.display()
65            );
66        }
67    }
68    Ok(())
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use std::path::PathBuf;
75
76    #[test]
77    fn accepte_chemins_relatifs_normaux() {
78        for p in ["history.db", "dir/config.toml", "./a/b.json"] {
79            assert!(validate_entry_path(&PathBuf::from(p)).is_ok(), "{p}");
80        }
81    }
82
83    #[test]
84    fn rejette_remontee_parent() {
85        for p in ["../evil", "a/../../evil", "../../etc/passwd"] {
86            assert!(validate_entry_path(&PathBuf::from(p)).is_err(), "{p}");
87        }
88    }
89
90    #[test]
91    fn rejette_chemin_absolu() {
92        for p in ["/tmp/evil", "/etc/passwd"] {
93            assert!(validate_entry_path(&PathBuf::from(p)).is_err(), "{p}");
94        }
95    }
96
97    #[test]
98    fn safe_unpack_extrait_archive_saine() {
99        use flate2::write::GzEncoder;
100        use flate2::Compression;
101
102        let mut header = tar::Header::new_gnu();
103        let data = b"hello";
104        header.set_size(data.len() as u64);
105        header.set_mode(0o644);
106        header.set_cksum();
107        let enc = GzEncoder::new(Vec::new(), Compression::default());
108        let mut builder = tar::Builder::new(enc);
109        builder
110            .append_data(&mut header, "sub/file.txt", &data[..])
111            .unwrap();
112        let bytes = builder.into_inner().unwrap().finish().unwrap();
113
114        let tmp = std::env::temp_dir().join(format!("mnemo-archive-test-{}", std::process::id()));
115        let _ = std::fs::remove_dir_all(&tmp);
116        std::fs::create_dir_all(&tmp).unwrap();
117        let archive = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
118        safe_unpack(archive, &tmp).unwrap();
119        assert_eq!(std::fs::read(tmp.join("sub/file.txt")).unwrap(), data);
120        let _ = std::fs::remove_dir_all(&tmp);
121    }
122
123    #[test]
124    fn safe_unpack_rejette_path_traversal() {
125        // Archive construite à la main avec un chemin `../evil` (le champ nom du
126        // header est écrit directement pour contourner la validation du builder).
127        use flate2::write::GzEncoder;
128        use flate2::Compression;
129
130        let data = b"evil";
131        let mut header = tar::Header::new_gnu();
132        header.set_size(data.len() as u64);
133        header.set_mode(0o644);
134        header.set_entry_type(tar::EntryType::Regular);
135        {
136            let gnu = header.as_gnu_mut().unwrap();
137            let name = b"../evil.txt";
138            gnu.name[..name.len()].copy_from_slice(name);
139        }
140        header.set_cksum();
141        let enc = GzEncoder::new(Vec::new(), Compression::default());
142        let mut builder = tar::Builder::new(enc);
143        builder.append(&header, &data[..]).unwrap();
144        let bytes = builder.into_inner().unwrap().finish().unwrap();
145
146        let tmp = std::env::temp_dir().join(format!(
147            "mnemo-archive-evil-{}-{}",
148            std::process::id(),
149            std::time::SystemTime::now()
150                .duration_since(std::time::UNIX_EPOCH)
151                .map(|d| d.as_nanos())
152                .unwrap_or(0)
153        ));
154        let _ = std::fs::remove_dir_all(&tmp);
155        std::fs::create_dir_all(&tmp).unwrap();
156        let archive = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
157        let result = safe_unpack(archive, &tmp);
158        assert!(result.is_err(), "le path traversal doit être rejeté");
159        // Rien ne doit avoir été écrit hors du tempdir.
160        assert!(!tmp.parent().unwrap().join("evil.txt").exists());
161        let _ = std::fs::remove_dir_all(&tmp);
162    }
163}