From c539bcce7b5db9ebe86195741ab8641b737cceab Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:39:20 +0800 Subject: [PATCH 1/2] fix: block unsafe tar extraction paths --- fastembed/common/model_management.py | 34 +++++++++++++++--- tests/test_model_management.py | 54 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 tests/test_model_management.py diff --git a/fastembed/common/model_management.py b/fastembed/common/model_management.py index 35e68236..90db3790 100644 --- a/fastembed/common/model_management.py +++ b/fastembed/common/model_management.py @@ -304,20 +304,44 @@ 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: + cache_path = Path(cache_dir).resolve() + members = list(tar.getmembers()) + for member in members: + cls._validate_tar_member(member, cache_path) + # Extract all files into the cache directory - tar.extractall( - path=cache_dir, - ) - except tarfile.TarError as e: + try: + tar.extractall(path=cache_dir, members=members, filter="data") + except TypeError: # pragma: no cover - Python < 3.12 + 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}") return cache_dir + @staticmethod + def _validate_tar_member(member: tarfile.TarInfo, cache_path: Path) -> None: + target_path = (cache_path / member.name).resolve() + if not target_path.is_relative_to(cache_path): + raise ValueError(f"Unsafe tar member path: {member.name}") + + if member.issym() or member.islnk(): + link_name = Path(member.linkname) + if member.issym(): + link_target = ( + link_name if link_name.is_absolute() else target_path.parent / link_name + ) + else: + link_target = link_name if link_name.is_absolute() else cache_path / link_name + + if not link_target.resolve().is_relative_to(cache_path): + raise ValueError(f"Unsafe tar link target: {member.name} -> {member.linkname}") + @classmethod def retrieve_model_gcs( cls, diff --git a/tests/test_model_management.py b/tests/test_model_management.py new file mode 100644 index 00000000..9d66119d --- /dev/null +++ b/tests/test_model_management.py @@ -0,0 +1,54 @@ +import io +import tarfile + +import pytest + +from fastembed.common.model_management import ModelManagement + + +def _add_file(tar: tarfile.TarFile, name: str, data: bytes) -> None: + info = tarfile.TarInfo(name=name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + +def test_decompress_to_cache_extracts_safe_members(tmp_path): + archive_path = tmp_path / "model.tar.gz" + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + + with tarfile.open(archive_path, "w:gz") as tar: + _add_file(tar, "model/config.json", b"{}") + + assert ModelManagement.decompress_to_cache(str(archive_path), str(cache_dir)) == str(cache_dir) + assert (cache_dir / "model" / "config.json").read_bytes() == b"{}" + + +def test_decompress_to_cache_rejects_member_path_traversal(tmp_path): + archive_path = tmp_path / "model.tar.gz" + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + outside_path = tmp_path / "outside.txt" + + with tarfile.open(archive_path, "w:gz") as tar: + _add_file(tar, "../outside.txt", b"owned") + + with pytest.raises(ValueError, match="Unsafe tar member path"): + ModelManagement.decompress_to_cache(str(archive_path), str(cache_dir)) + + assert not outside_path.exists() + + +def test_decompress_to_cache_rejects_symlink_path_traversal(tmp_path): + archive_path = tmp_path / "model.tar.gz" + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + + with tarfile.open(archive_path, "w:gz") as tar: + link = tarfile.TarInfo(name="model/escape") + link.type = tarfile.SYMTYPE + link.linkname = "../../outside.txt" + tar.addfile(link) + + with pytest.raises(ValueError, match="Unsafe tar link target"): + ModelManagement.decompress_to_cache(str(archive_path), str(cache_dir)) From 8db4dacc08b5f22249a88dd7243c521f5e690ca8 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Tue, 22 Sep 2026 12:28:54 +0700 Subject: [PATCH 2/2] fix: correct the version gate and fallback in tar extraction --- fastembed/common/model_management.py | 79 ++++++++++++++++++++-------- tests/test_model_management.py | 54 ------------------- 2 files changed, 56 insertions(+), 77 deletions(-) delete mode 100644 tests/test_model_management.py diff --git a/fastembed/common/model_management.py b/fastembed/common/model_management.py index 90db3790..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,15 +304,17 @@ 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: - cache_path = Path(cache_dir).resolve() - members = list(tar.getmembers()) - for member in members: - cls._validate_tar_member(member, cache_path) - - # Extract all files into the cache directory - try: - tar.extractall(path=cache_dir, members=members, filter="data") - except TypeError: # pragma: no cover - Python < 3.12 + 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, @@ -320,27 +322,58 @@ def decompress_to_cache(cls, targz_path: str, cache_dir: str) -> str: # and raise the error again 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 _validate_tar_member(member: tarfile.TarInfo, cache_path: Path) -> None: - target_path = (cache_path / member.name).resolve() - if not target_path.is_relative_to(cache_path): + 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(): - link_name = Path(member.linkname) - if member.issym(): - link_target = ( - link_name if link_name.is_absolute() else target_path.parent / link_name - ) - else: - link_target = link_name if link_name.is_absolute() else cache_path / link_name - - if not link_target.resolve().is_relative_to(cache_path): + 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( diff --git a/tests/test_model_management.py b/tests/test_model_management.py deleted file mode 100644 index 9d66119d..00000000 --- a/tests/test_model_management.py +++ /dev/null @@ -1,54 +0,0 @@ -import io -import tarfile - -import pytest - -from fastembed.common.model_management import ModelManagement - - -def _add_file(tar: tarfile.TarFile, name: str, data: bytes) -> None: - info = tarfile.TarInfo(name=name) - info.size = len(data) - tar.addfile(info, io.BytesIO(data)) - - -def test_decompress_to_cache_extracts_safe_members(tmp_path): - archive_path = tmp_path / "model.tar.gz" - cache_dir = tmp_path / "cache" - cache_dir.mkdir() - - with tarfile.open(archive_path, "w:gz") as tar: - _add_file(tar, "model/config.json", b"{}") - - assert ModelManagement.decompress_to_cache(str(archive_path), str(cache_dir)) == str(cache_dir) - assert (cache_dir / "model" / "config.json").read_bytes() == b"{}" - - -def test_decompress_to_cache_rejects_member_path_traversal(tmp_path): - archive_path = tmp_path / "model.tar.gz" - cache_dir = tmp_path / "cache" - cache_dir.mkdir() - outside_path = tmp_path / "outside.txt" - - with tarfile.open(archive_path, "w:gz") as tar: - _add_file(tar, "../outside.txt", b"owned") - - with pytest.raises(ValueError, match="Unsafe tar member path"): - ModelManagement.decompress_to_cache(str(archive_path), str(cache_dir)) - - assert not outside_path.exists() - - -def test_decompress_to_cache_rejects_symlink_path_traversal(tmp_path): - archive_path = tmp_path / "model.tar.gz" - cache_dir = tmp_path / "cache" - cache_dir.mkdir() - - with tarfile.open(archive_path, "w:gz") as tar: - link = tarfile.TarInfo(name="model/escape") - link.type = tarfile.SYMTYPE - link.linkname = "../../outside.txt" - tar.addfile(link) - - with pytest.raises(ValueError, match="Unsafe tar link target"): - ModelManagement.decompress_to_cache(str(archive_path), str(cache_dir))