1+ """
2+ Safe tar file extraction wrapper.
3+
4+ Addresses CVE-2025-4435 (tarfile)
5+ """
6+
7+ import tarfile
8+ import os
9+ import tempfile
10+ from pathlib import Path
11+ from typing import Optional , Union , List
12+
13+ from .exceptions import UnsafeTarError , SecurityWarning
14+ import warnings
15+
16+
17+ class SafeTarExtractor :
18+ """Safe tar file extractor with security protections."""
19+
20+ DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
21+ DEFAULT_MAX_FILES = 1000
22+
23+ def __init__ (
24+ self ,
25+ max_file_size : int = DEFAULT_MAX_FILE_SIZE ,
26+ max_files : int = DEFAULT_MAX_FILES ,
27+ preserve_permissions : bool = False ,
28+ allow_absolute_paths : bool = False ,
29+ block_devices : bool = True ,
30+ ):
31+ self .max_file_size = max_file_size
32+ self .max_files = max_files
33+ self .preserve_permissions = preserve_permissions
34+ self .allow_absolute_paths = allow_absolute_paths
35+ self .block_devices = block_devices
36+
37+ self .blocked_types = set ()
38+ if block_devices :
39+ self .blocked_types .update ({
40+ tarfile .BLKTYPE ,
41+ tarfile .CHRTYPE ,
42+ tarfile .FIFOTYPE ,
43+ })
44+
45+ def extract (
46+ self ,
47+ tar_path : Union [str , Path ],
48+ extract_path : Union [str , Path ],
49+ overwrite : bool = False ,
50+ ) -> List [Path ]:
51+ """Safely extract a tar file."""
52+ tar_path = Path (tar_path )
53+ extract_path = Path (extract_path )
54+
55+ if not tar_path .exists ():
56+ raise FileNotFoundError (f"Tar file not found: { tar_path } " )
57+
58+ extract_path .mkdir (parents = True , exist_ok = True )
59+
60+ extracted_files = []
61+ file_count = 0
62+
63+ try :
64+ with tarfile .open (tar_path , 'r:*' ) as tar :
65+ self ._validate_tar_header (tar )
66+
67+ for member in tar .getmembers ():
68+ file_count += 1
69+ if file_count > self .max_files :
70+ raise UnsafeTarError (
71+ f"Too many files in archive (>{ self .max_files } )"
72+ )
73+
74+ self ._validate_member (member , extract_path , overwrite )
75+
76+ if member .size > self .max_file_size :
77+ raise UnsafeTarError (
78+ f"File { member .name } exceeds size limit "
79+ f"({ member .size } > { self .max_file_size } )"
80+ )
81+
82+ target_path = self ._get_safe_path (member , extract_path )
83+
84+ try :
85+ if member .isfile ():
86+ self ._extract_file (tar , member , target_path )
87+ elif member .isdir ():
88+ target_path .mkdir (parents = True , exist_ok = True )
89+ elif member .issym () or member .islnk ():
90+ self ._extract_symlink (member , target_path )
91+ else :
92+ self ._extract_other (member , target_path )
93+
94+ extracted_files .append (target_path )
95+
96+ if self .preserve_permissions :
97+ self ._preserve_metadata (member , target_path )
98+
99+ except Exception as e :
100+ raise UnsafeTarError (f"Failed to extract { member .name } : { str (e )} " )
101+
102+ except tarfile .TarError as e :
103+ raise UnsafeTarError (f"Invalid tar file: { str (e )} " )
104+
105+ return extracted_files
106+
107+ def _validate_tar_header (self , tar : tarfile .TarFile ) -> None :
108+ """Validate tar file header."""
109+ try :
110+ if not tar .getmembers ():
111+ warnings .warn (
112+ "Tar file appears to be empty" ,
113+ SecurityWarning ,
114+ stacklevel = 2
115+ )
116+ except (tarfile .TarError , EOFError ) as e :
117+ raise UnsafeTarError (f"Invalid tar header: { str (e )} " )
118+
119+ def _validate_member (
120+ self ,
121+ member : tarfile .TarInfo ,
122+ extract_path : Path ,
123+ overwrite : bool
124+ ) -> None :
125+ """Validate a tar member for safety."""
126+ if not self .allow_absolute_paths and member .path .startswith ('/' ):
127+ raise UnsafeTarError (
128+ f"Absolute path not allowed: { member .name } "
129+ )
130+
131+ target_path = self ._get_safe_path (member , extract_path )
132+
133+ # Check for path traversal
134+ try :
135+ target_abs = target_path .resolve ()
136+ extract_abs = extract_path .resolve ()
137+ if not str (target_abs ).startswith (str (extract_abs )):
138+ raise UnsafeTarError (
139+ f"Path traversal detected: { member .name } -> { target_path } "
140+ )
141+ except (OSError , ValueError ):
142+ target_str = str (target_path .absolute ())
143+ extract_str = str (extract_path .absolute ())
144+ if not target_str .startswith (extract_str ):
145+ raise UnsafeTarError (
146+ f"Path traversal detected: { member .name } -> { target_path } "
147+ )
148+
149+ if target_path .exists () and not overwrite :
150+ raise UnsafeTarError (
151+ f"File already exists and overwrite=False: { target_path } "
152+ )
153+
154+ if member .type in self .blocked_types :
155+ raise UnsafeTarError (
156+ f"Blocked file type for { member .name } : { member .type } "
157+ )
158+
159+ def _get_safe_path (self , member : tarfile .TarInfo , extract_path : Path ) -> Path :
160+ """Get safe path for extraction."""
161+ path = os .path .normpath (member .name )
162+ if path .startswith ('/' ):
163+ path = path [1 :] if not self .allow_absolute_paths else path
164+ return extract_path / path
165+
166+ def _extract_file (
167+ self ,
168+ tar : tarfile .TarFile ,
169+ member : tarfile .TarInfo ,
170+ target_path : Path
171+ ) -> None :
172+ """Extract a regular file."""
173+ target_path .parent .mkdir (parents = True , exist_ok = True )
174+
175+ with tempfile .NamedTemporaryFile (
176+ dir = target_path .parent ,
177+ prefix = '.tmp_' ,
178+ delete = False
179+ ) as tmp :
180+ try :
181+ src = tar .extractfile (member )
182+ if src is None :
183+ raise UnsafeTarError (f"Could not extract file: { member .name } " )
184+
185+ bytes_copied = 0
186+ chunk_size = 8192
187+ while bytes_copied < member .size :
188+ chunk = src .read (min (chunk_size , member .size - bytes_copied ))
189+ if not chunk :
190+ break
191+ bytes_copied += len (chunk )
192+ tmp .write (chunk )
193+
194+ src .close ()
195+ tmp .close ()
196+
197+ if target_path .exists ():
198+ target_path .unlink ()
199+ os .rename (tmp .name , target_path )
200+
201+ except Exception :
202+ if os .path .exists (tmp .name ):
203+ os .unlink (tmp .name )
204+ raise
205+
206+ def _extract_symlink (self , member : tarfile .TarInfo , target_path : Path ) -> None :
207+ """Extract a symlink."""
208+ if member .linkpath :
209+ target = Path (member .linkpath )
210+ if target .is_absolute () and not self .allow_absolute_paths :
211+ raise UnsafeTarError (
212+ f"Absolute symlink target not allowed: { member .linkpath } "
213+ )
214+
215+ target_path .parent .mkdir (parents = True , exist_ok = True )
216+ if target_path .exists () or target_path .is_symlink ():
217+ target_path .unlink ()
218+
219+ target_path .symlink_to (member .linkpath )
220+
221+ def _extract_other (self , member : tarfile .TarInfo , target_path : Path ) -> None :
222+ """Extract other file types."""
223+ target_path .parent .mkdir (parents = True , exist_ok = True )
224+ target_path .touch ()
225+ warnings .warn (
226+ f"Unsupported file type { member .type } for { member .name } " ,
227+ SecurityWarning ,
228+ stacklevel = 2
229+ )
230+
231+ def _preserve_metadata (self , member : tarfile .TarInfo , target_path : Path ) -> None :
232+ """Preserve file metadata."""
233+ try :
234+ if target_path .exists ():
235+ if member .mtime :
236+ os .utime (target_path , times = (member .mtime , member .mtime ))
237+ if member .mode :
238+ os .chmod (target_path , member .mode )
239+ except (OSError , PermissionError ) as e :
240+ warnings .warn (
241+ f"Could not preserve metadata for { target_path } : { str (e )} " ,
242+ SecurityWarning ,
243+ stacklevel = 2
244+ )
245+
246+
247+ def safe_extract (
248+ tar_path : Union [str , Path ],
249+ extract_path : Union [str , Path ],
250+ ** kwargs
251+ ) -> List [Path ]:
252+ """Convenience function to safely extract a tar file."""
253+ extractor = SafeTarExtractor (** kwargs )
254+ return extractor .extract (tar_path , extract_path )
0 commit comments