Skip to content

Commit a2e062f

Browse files
authored
Merge commit from fork
Symlinks first fix
2 parents c6ba201 + 0199ac2 commit a2e062f

File tree

5 files changed

+253
-71
lines changed

5 files changed

+253
-71
lines changed

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ mod compression;
4646
mod cp437;
4747
mod crc32;
4848
pub mod extra_fields;
49+
mod path;
4950
pub mod read;
5051
pub mod result;
5152
mod spec;

src/path.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//! Path manipulation utilities
2+
3+
use std::{
4+
ffi::OsStr,
5+
path::{Component, Path},
6+
};
7+
8+
/// Simplify a path by removing the prefix and parent directories and only return normal components
9+
pub(crate) fn simplified_components(input: &Path) -> Option<Vec<&OsStr>> {
10+
let mut out = Vec::new();
11+
for component in input.components() {
12+
match component {
13+
Component::Prefix(_) | Component::RootDir => return None,
14+
Component::ParentDir => {
15+
if out.pop().is_none() {
16+
return None;
17+
}
18+
}
19+
Component::Normal(_) => out.push(component.as_os_str()),
20+
Component::CurDir => (),
21+
}
22+
}
23+
Some(out)
24+
}

src/read.rs

Lines changed: 180 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ use crate::types::{
1616
use crate::zipcrypto::{ZipCryptoReader, ZipCryptoReaderValid, ZipCryptoValidator};
1717
use indexmap::IndexMap;
1818
use std::borrow::Cow;
19-
use std::ffi::OsString;
19+
use std::ffi::OsStr;
2020
use std::fs::create_dir_all;
2121
use std::io::{self, copy, prelude::*, sink, SeekFrom};
2222
use std::mem;
2323
use std::mem::size_of;
2424
use std::ops::Deref;
25-
use std::path::{Path, PathBuf};
25+
use std::path::{Component, Path, PathBuf};
2626
use std::sync::{Arc, OnceLock};
2727

2828
mod config;
@@ -318,6 +318,22 @@ impl<R: Read> Read for SeekableTake<'_, R> {
318318
}
319319
}
320320

321+
pub(crate) fn make_writable_dir_all<T: AsRef<Path>>(outpath: T) -> Result<(), ZipError> {
322+
create_dir_all(outpath.as_ref())?;
323+
#[cfg(unix)]
324+
{
325+
// Dirs must be writable until all normal files are extracted
326+
use std::os::unix::fs::PermissionsExt;
327+
std::fs::set_permissions(
328+
outpath.as_ref(),
329+
std::fs::Permissions::from_mode(
330+
0o700 | std::fs::metadata(outpath.as_ref())?.permissions().mode(),
331+
),
332+
)?;
333+
}
334+
Ok(())
335+
}
336+
321337
pub(crate) fn find_content<'a>(
322338
data: &ZipFileData,
323339
reader: &'a mut (impl Read + Seek),
@@ -433,6 +449,46 @@ pub(crate) fn make_reader(
433449
))))
434450
}
435451

452+
pub(crate) fn make_symlink(outpath: &PathBuf, target: Vec<u8>) -> ZipResult<()> {
453+
#[cfg(not(any(unix, windows)))]
454+
{
455+
let output = File::create(outpath.as_path());
456+
output.write_all(target)?;
457+
continue;
458+
}
459+
460+
let Ok(target) = String::from_utf8(target) else {
461+
return Err(ZipError::InvalidArchive("Invalid UTF-8 as symlink target"));
462+
};
463+
let target = Path::new(&target);
464+
465+
#[cfg(unix)]
466+
{
467+
std::os::unix::fs::symlink(target, outpath.as_path())?;
468+
}
469+
#[cfg(windows)]
470+
{
471+
let Ok(target) = String::from_utf8(target) else {
472+
return Err(ZipError::InvalidArchive("Invalid UTF-8 as symlink target"));
473+
};
474+
let target = target.into_boxed_str();
475+
let target_is_dir_from_archive = self.shared.files.contains_key(&target) && is_dir(&target);
476+
let target_is_dir = if target_is_dir_from_archive {
477+
true
478+
} else if let Ok(meta) = std::fs::metadata(&target) {
479+
meta.is_dir()
480+
} else {
481+
false
482+
};
483+
if target_is_dir {
484+
std::os::windows::fs::symlink_dir(target, outpath.as_path())?;
485+
} else {
486+
std::os::windows::fs::symlink_file(target, outpath.as_path())?;
487+
}
488+
}
489+
Ok(())
490+
}
491+
436492
#[derive(Debug)]
437493
pub(crate) struct CentralDirectoryInfo {
438494
pub(crate) archive_offset: u64,
@@ -720,7 +776,9 @@ impl<R: Read + Seek> ZipArchive<R> {
720776
}
721777

722778
/// Extract a Zip archive into a directory, overwriting files if they
723-
/// already exist. Paths are sanitized with [`ZipFile::enclosed_name`].
779+
/// already exist. Paths are sanitized with [`ZipFile::enclosed_name`]. Symbolic links are only
780+
/// created and followed if the target is within the destination directory (this is checked
781+
/// conservatively using [`std::fs::canonicalize`]).
724782
///
725783
/// Extraction is not atomic. If an error is encountered, some of the files
726784
/// may be left on disk. However, on Unix targets, no newly-created directories with part but
@@ -732,60 +790,33 @@ impl<R: Read + Seek> ZipArchive<R> {
732790
/// containing the target path in UTF-8.
733791
pub fn extract<P: AsRef<Path>>(&mut self, directory: P) -> ZipResult<()> {
734792
use std::fs;
793+
735794
#[cfg(unix)]
736795
let mut files_by_unix_mode = Vec::new();
796+
797+
let directory = directory.as_ref().canonicalize()?;
737798
for i in 0..self.len() {
738799
let mut file = self.by_index(i)?;
739-
let filepath = file
740-
.enclosed_name()
741-
.ok_or(InvalidArchive("Invalid file path"))?;
742800

743-
let outpath = directory.as_ref().join(filepath);
801+
let mut outpath = directory.clone();
802+
file.safe_prepare_path(&directory, &mut outpath)?;
744803

745-
if file.is_dir() {
746-
Self::make_writable_dir_all(&outpath)?;
747-
continue;
748-
}
749804
let symlink_target = if file.is_symlink() && (cfg!(unix) || cfg!(windows)) {
750805
let mut target = Vec::with_capacity(file.size() as usize);
751806
file.read_to_end(&mut target)?;
752807
Some(target)
753808
} else {
809+
if file.is_dir() {
810+
crate::read::make_writable_dir_all(&outpath)?;
811+
continue;
812+
}
754813
None
755814
};
815+
756816
drop(file);
757-
if let Some(p) = outpath.parent() {
758-
Self::make_writable_dir_all(p)?;
759-
}
817+
760818
if let Some(target) = symlink_target {
761-
#[cfg(unix)]
762-
{
763-
use std::os::unix::ffi::OsStringExt;
764-
let target = OsString::from_vec(target);
765-
std::os::unix::fs::symlink(&target, outpath.as_path())?;
766-
}
767-
#[cfg(windows)]
768-
{
769-
let Ok(target) = String::from_utf8(target) else {
770-
return Err(ZipError::InvalidArchive("Invalid UTF-8 as symlink target"));
771-
};
772-
let target = target.into_boxed_str();
773-
let target_is_dir_from_archive =
774-
self.shared.files.contains_key(&target) && is_dir(&target);
775-
let target_path = directory.as_ref().join(OsString::from(target.to_string()));
776-
let target_is_dir = if target_is_dir_from_archive {
777-
true
778-
} else if let Ok(meta) = std::fs::metadata(&target_path) {
779-
meta.is_dir()
780-
} else {
781-
false
782-
};
783-
if target_is_dir {
784-
std::os::windows::fs::symlink_dir(target_path, outpath.as_path())?;
785-
} else {
786-
std::os::windows::fs::symlink_file(target_path, outpath.as_path())?;
787-
}
788-
}
819+
make_symlink(&outpath, target)?;
789820
continue;
790821
}
791822
let mut file = self.by_index(i)?;
@@ -815,22 +846,6 @@ impl<R: Read + Seek> ZipArchive<R> {
815846
Ok(())
816847
}
817848

818-
fn make_writable_dir_all<T: AsRef<Path>>(outpath: T) -> Result<(), ZipError> {
819-
create_dir_all(outpath.as_ref())?;
820-
#[cfg(unix)]
821-
{
822-
// Dirs must be writable until all normal files are extracted
823-
use std::os::unix::fs::PermissionsExt;
824-
std::fs::set_permissions(
825-
outpath.as_ref(),
826-
std::fs::Permissions::from_mode(
827-
0o700 | std::fs::metadata(outpath.as_ref())?.permissions().mode(),
828-
),
829-
)?;
830-
}
831-
Ok(())
832-
}
833-
834849
/// Number of files contained in this zip.
835850
pub fn len(&self) -> usize {
836851
self.shared.files.len()
@@ -1404,6 +1419,93 @@ impl<'a> ZipFile<'a> {
14041419
self.get_metadata().enclosed_name()
14051420
}
14061421

1422+
pub(crate) fn simplified_components(&self) -> Option<Vec<&OsStr>> {
1423+
self.get_metadata().simplified_components()
1424+
}
1425+
1426+
/// Prepare the path for extraction by creating necessary missing directories and checking for symlinks to be contained within the base path.
1427+
///
1428+
/// `base_path` parameter is assumed to be canonicalized.
1429+
pub(crate) fn safe_prepare_path(
1430+
&self,
1431+
base_path: &Path,
1432+
outpath: &mut PathBuf,
1433+
) -> ZipResult<()> {
1434+
let components = self
1435+
.simplified_components()
1436+
.ok_or(InvalidArchive("Invalid file path"))?;
1437+
1438+
let components_len = components.len();
1439+
1440+
for (is_last, component) in components
1441+
.into_iter()
1442+
.enumerate()
1443+
.map(|(i, c)| (i == components_len - 1, c))
1444+
{
1445+
// we can skip the target directory itself because the base path is assumed to be "trusted" (if the user say extract to a symlink we can follow it)
1446+
outpath.push(component);
1447+
1448+
// check if the path is a symlink, the target must be _inherently_ within the directory
1449+
for limit in (0..5u8).rev() {
1450+
let meta = match std::fs::symlink_metadata(&outpath) {
1451+
Ok(meta) => meta,
1452+
Err(e) if e.kind() == io::ErrorKind::NotFound => {
1453+
if !is_last {
1454+
crate::read::make_writable_dir_all(&outpath)?;
1455+
}
1456+
break;
1457+
}
1458+
Err(e) => return Err(e.into()),
1459+
};
1460+
1461+
if !meta.is_symlink() {
1462+
break;
1463+
}
1464+
1465+
if limit == 0 {
1466+
return Err(InvalidArchive("Extraction followed a symlink too deep"));
1467+
}
1468+
1469+
// note that we cannot accept links that do not inherently resolve to a path inside the directory to prevent:
1470+
// - disclosure of unrelated path exists (no check for a path exist and then ../ out)
1471+
// - issues with file-system specific path resolution (case sensitivity, etc)
1472+
let target = std::fs::read_link(&outpath)?;
1473+
1474+
if !crate::path::simplified_components(&target)
1475+
.ok_or(InvalidArchive("Invalid symlink target path"))?
1476+
.starts_with(
1477+
&crate::path::simplified_components(base_path)
1478+
.ok_or(InvalidArchive("Invalid base path"))?,
1479+
)
1480+
{
1481+
let is_absolute_enclosed = base_path
1482+
.components()
1483+
.map(Some)
1484+
.chain(std::iter::once(None))
1485+
.zip(target.components().map(Some).chain(std::iter::repeat(None)))
1486+
.all(|(a, b)| match (a, b) {
1487+
// both components are normal
1488+
(Some(Component::Normal(a)), Some(Component::Normal(b))) => a == b,
1489+
// both components consumed fully
1490+
(None, None) => true,
1491+
// target consumed fully but base path is not
1492+
(Some(_), None) => false,
1493+
// base path consumed fully but target is not (and normal)
1494+
(None, Some(Component::CurDir | Component::Normal(_))) => true,
1495+
_ => false,
1496+
});
1497+
1498+
if !is_absolute_enclosed {
1499+
return Err(InvalidArchive("Symlink is not inherently safe"));
1500+
}
1501+
}
1502+
1503+
outpath.push(target);
1504+
}
1505+
}
1506+
Ok(())
1507+
}
1508+
14071509
/// Get the comment of the file
14081510
pub fn comment(&self) -> &str {
14091511
&self.get_metadata().file_comment
@@ -1871,4 +1973,24 @@ mod test {
18711973
}
18721974
Ok(())
18731975
}
1976+
1977+
/// Symlinks being extracted shouldn't be followed out of the destination directory.
1978+
#[test]
1979+
fn test_cannot_symlink_outside_destination() -> ZipResult<()> {
1980+
use std::fs::create_dir;
1981+
1982+
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
1983+
writer.add_symlink("symlink/", "../dest-sibling/", SimpleFileOptions::default())?;
1984+
writer.start_file("symlink/dest-file", SimpleFileOptions::default())?;
1985+
let mut reader = writer.finish_into_readable()?;
1986+
let dest_parent =
1987+
TempDir::with_prefix("read__test_cannot_symlink_outside_destination").unwrap();
1988+
let dest_sibling = dest_parent.path().join("dest-sibling");
1989+
create_dir(&dest_sibling)?;
1990+
let dest = dest_parent.path().join("dest");
1991+
create_dir(&dest)?;
1992+
assert!(reader.extract(dest).is_err());
1993+
assert!(!dest_sibling.join("dest-file").exists());
1994+
Ok(())
1995+
}
18741996
}

0 commit comments

Comments
 (0)