@@ -16,13 +16,13 @@ use crate::types::{
1616use crate :: zipcrypto:: { ZipCryptoReader , ZipCryptoReaderValid , ZipCryptoValidator } ;
1717use indexmap:: IndexMap ;
1818use std:: borrow:: Cow ;
19- use std:: ffi:: OsString ;
19+ use std:: ffi:: OsStr ;
2020use std:: fs:: create_dir_all;
2121use std:: io:: { self , copy, prelude:: * , sink, SeekFrom } ;
2222use std:: mem;
2323use std:: mem:: size_of;
2424use std:: ops:: Deref ;
25- use std:: path:: { Path , PathBuf } ;
25+ use std:: path:: { Component , Path , PathBuf } ;
2626use std:: sync:: { Arc , OnceLock } ;
2727
2828mod 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+
321337pub ( 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 ) ]
437493pub ( 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