diff --git a/fastembed/common/model_management.py b/fastembed/common/model_management.py index 35e68236..7919eb9e 100644 --- a/fastembed/common/model_management.py +++ b/fastembed/common/model_management.py @@ -4,7 +4,7 @@ import shutil import tarfile from copy import deepcopy -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any, TypeVar, Generic import requests @@ -304,20 +304,77 @@ def decompress_to_cache(cls, targz_path: str, cache_dir: str) -> str: try: # Open the tar.gz file with tarfile.open(targz_path, "r:gz") as tar: - # Extract all files into the cache directory - tar.extractall( - path=cache_dir, - ) - except tarfile.TarError as e: + if hasattr(tarfile, "data_filter"): + # PEP 706 extraction filter: rejects absolute paths, traversal and + # escaping links while extracting, so symlinks planted earlier in the + # archive cannot be used to redirect later members. + tar.extractall(path=cache_dir, filter="data") + else: + # Python 3.10.0 - 3.10.11 ship no extraction filter, so vet the members + # by hand before writing anything to disk. + members = tar.getmembers() + for member in members: + cls._validate_tar_member(member) + tar.extractall(path=cache_dir, members=members) + except (tarfile.TarError, ValueError) as e: # If any error occurs while opening or extracting the tar.gz file, # delete the cache directory (if it was created in this function) # and raise the error again - if "tmp" in cache_dir: + if "tmp" in cache_dir and os.path.exists(cache_dir): shutil.rmtree(cache_dir) - raise ValueError(f"An error occurred while decompressing {targz_path}: {e}") + raise ValueError(f"An error occurred while decompressing {targz_path}: {e}") from e return cache_dir + @staticmethod + def _is_unsafe_tar_path(path: str) -> bool: + """Checks whether a tar path may point outside the directory it is extracted into. + + The check is deliberately lexical. Resolving the path against the extraction + directory would be unsound before extraction: a member such as `link/../escape` + looks harmless while `link` does not exist yet, and only escapes once an earlier + member has been written as a symlink. Any `..` component is therefore rejected + outright, which keeps every member and link target under the extraction directory. + + Args: + path (str): A tar member name or link target. + + Returns: + bool: True if the path is absolute, drive-qualified or contains a `..` component. + """ + # Tar names use POSIX separators, but a crafted archive may carry Windows-style + # names ("C:\\evil", "..\\evil") that os.path.join would honour on Windows. + windows_path = PureWindowsPath(path) + return ( + PurePosixPath(path).is_absolute() + or windows_path.is_absolute() + or bool(windows_path.drive) + or ".." in windows_path.parts + ) + + @classmethod + def _validate_tar_member(cls, member: tarfile.TarInfo) -> None: + """Rejects a tar member that could write outside the extraction directory. + + Only used on interpreters without PEP 706 extraction filters, which do this (and + more) themselves. + + Args: + member (tarfile.TarInfo): The member about to be extracted. + + Raises: + ValueError: If the member path, its link target, or its type is unsafe. + """ + if cls._is_unsafe_tar_path(member.name): + raise ValueError(f"Unsafe tar member path: {member.name}") + + if member.issym() or member.islnk(): + if cls._is_unsafe_tar_path(member.linkname): + raise ValueError(f"Unsafe tar link target: {member.name} -> {member.linkname}") + elif not (member.isfile() or member.isdir()): + # Devices, fifos and the like have no place in a model archive. + raise ValueError(f"Unsupported tar member type: {member.name}") + @classmethod def retrieve_model_gcs( cls,