@@ -35,6 +35,45 @@ def _raise_if_windows_member_path(member_name: str) -> None:
3535 raise UnsafeTarMemberError (member = member_name , reason = "windows path separator" )
3636
3737
38+ def _normalize_posix_path_without_root (path : PurePosixPath ) -> tuple [str , ...] | None :
39+ normalized : list [str ] = []
40+ for part in path .parts :
41+ if part in ("" , "." , "/" ):
42+ continue
43+ if part == ".." :
44+ if not normalized :
45+ return None
46+ normalized .pop ()
47+ continue
48+ normalized .append (part )
49+ return tuple (normalized )
50+
51+
52+ def _validate_symlink_target (
53+ member : tarfile .TarInfo ,
54+ * ,
55+ rel_path : Path ,
56+ allow_external_symlink_targets : bool ,
57+ ) -> None :
58+ if not member .issym () or allow_external_symlink_targets :
59+ return
60+
61+ target = PurePosixPath (member .linkname )
62+ if target .is_absolute ():
63+ raise UnsafeTarMemberError (
64+ member = member .name ,
65+ reason = f"absolute symlink target not allowed: { member .linkname } " ,
66+ )
67+
68+ member_parent = PurePosixPath (rel_path .as_posix ()).parent
69+ normalized = _normalize_posix_path_without_root (member_parent / target )
70+ if normalized is None :
71+ raise UnsafeTarMemberError (
72+ member = member .name ,
73+ reason = f"symlink target escapes archive root: { member .linkname } " ,
74+ )
75+
76+
3877def safe_tar_member_rel_path (
3978 member : tarfile .TarInfo ,
4079 * ,
@@ -199,6 +238,7 @@ def validate_tarfile(
199238 skip_rel_paths : Iterable [str | Path ] = (),
200239 root_name : str | None = None ,
201240 allow_symlinks : bool = True ,
241+ allow_external_symlink_targets : bool = True ,
202242) -> None :
203243 """Validate a workspace tar before handing it to a local or remote extractor.
204244
@@ -235,6 +275,11 @@ def validate_tarfile(
235275 members_by_rel_path [rel_path ] = member
236276
237277 if member .issym ():
278+ _validate_symlink_target (
279+ member ,
280+ rel_path = rel_path ,
281+ allow_external_symlink_targets = allow_external_symlink_targets ,
282+ )
238283 if rel_path in rejected_symlink_rel_paths :
239284 raise UnsafeTarMemberError (
240285 member = member .name ,
@@ -266,6 +311,7 @@ def validate_tar_bytes(
266311 reject_symlink_rel_paths : Iterable [str | Path ] = (),
267312 skip_rel_paths : Iterable [str | Path ] = (),
268313 root_name : str | None = None ,
314+ allow_external_symlink_targets : bool = True ,
269315) -> None :
270316 """Validate raw workspace tar bytes with the shared safe tar policy."""
271317
@@ -276,14 +322,20 @@ def validate_tar_bytes(
276322 reject_symlink_rel_paths = reject_symlink_rel_paths ,
277323 skip_rel_paths = skip_rel_paths ,
278324 root_name = root_name ,
325+ allow_external_symlink_targets = allow_external_symlink_targets ,
279326 )
280327 except UnsafeTarMemberError :
281328 raise
282329 except (tarfile .TarError , OSError ) as e :
283330 raise UnsafeTarMemberError (member = "<tar>" , reason = "invalid tar stream" ) from e
284331
285332
286- def safe_extract_tarfile (tar : tarfile .TarFile , * , root : Path ) -> None :
333+ def safe_extract_tarfile (
334+ tar : tarfile .TarFile ,
335+ * ,
336+ root : Path ,
337+ allow_external_symlink_targets : bool = True ,
338+ ) -> None :
287339 """
288340 Safely extract a tar archive into `root`.
289341
@@ -302,7 +354,10 @@ def safe_extract_tarfile(tar: tarfile.TarFile, *, root: Path) -> None:
302354 root_resolved = root .resolve ()
303355
304356 members = tar .getmembers ()
305- validate_tarfile (tar )
357+ validate_tarfile (
358+ tar ,
359+ allow_external_symlink_targets = allow_external_symlink_targets ,
360+ )
306361
307362 def _prepare_replaceable_leaf (* , dest : Path , rel_path : Path , name : str ) -> None :
308363 _ensure_no_symlink_parents (root = root_resolved , dest = dest , check_leaf = False )
0 commit comments