diff --git a/test/test_datasets_utils.py b/test/test_datasets_utils.py index 461688405d7..c936e70f57f 100644 --- a/test/test_datasets_utils.py +++ b/test/test_datasets_utils.py @@ -1,5 +1,6 @@ import contextlib import gzip +import io import os import pathlib import re @@ -235,6 +236,28 @@ def create_archive(root, extension, mode, content="this is the content"): with open(file) as fh: assert fh.read() == content + @pytest.mark.parametrize("member_name", ["../escaped.txt", "../../escaped.txt"]) + def test_extract_tar_rejects_path_traversal(self, member_name, tmpdir): + # Regression test for a path-traversal vulnerability (issue #9517): a tar + # member with a "../" component must not be extracted outside to_path. + tmpdir = pathlib.Path(tmpdir) + to_path = tmpdir / "extract_here" + to_path.mkdir() + escaped_target = (to_path / member_name).resolve() + assert to_path.resolve() not in escaped_target.parents # target is really outside + + archive = tmpdir / "malicious.tar" + payload = b"path traversal payload" + with tarfile.open(archive, mode="w") as tar: + info = tarfile.TarInfo(member_name) + info.size = len(payload) + tar.addfile(info, io.BytesIO(payload)) + + with pytest.raises((tarfile.TarError, RuntimeError)): + utils.extract_archive(str(archive), str(to_path)) + + assert not escaped_target.exists() + def test_verify_str_arg(self): assert "a" == utils.verify_str_arg("a", "arg", ("a",)) pytest.raises(ValueError, utils.verify_str_arg, 0, ("a",), "arg") diff --git a/torchvision/datasets/utils.py b/torchvision/datasets/utils.py index 0b6670800d2..448ba82acf2 100644 --- a/torchvision/datasets/utils.py +++ b/torchvision/datasets/utils.py @@ -206,11 +206,35 @@ def download_file_from_google_drive( raise RuntimeError("File not found or corrupted.") +def _reject_escaping_tar_members(tar: tarfile.TarFile, to_path: Union[str, pathlib.Path]) -> None: + """Path-traversal guard for Python versions without the PEP 706 ``data`` filter. + + Rejects any member whose resolved path, or link target, would fall outside + ``to_path`` before extraction writes anything. + """ + base = os.path.realpath(to_path) + for member in tar.getmembers(): + target = os.path.realpath(os.path.join(base, member.name)) + if os.path.commonpath([base, target]) != base: + raise RuntimeError(f"Refusing to extract tar member '{member.name}' outside of '{to_path}'.") + if member.issym() or member.islnk(): + link_target = os.path.realpath(os.path.join(os.path.dirname(target), member.linkname)) + if os.path.commonpath([base, link_target]) != base: + raise RuntimeError(f"Refusing to extract tar member '{member.name}' linking outside of '{to_path}'.") + + def _extract_tar( from_path: Union[str, pathlib.Path], to_path: Union[str, pathlib.Path], compression: Optional[str] ) -> None: with tarfile.open(from_path, f"r:{compression[1:]}" if compression else "r") as tar: - tar.extractall(to_path) + # PEP 706 ``data`` filter rejects path traversal, absolute paths, and links + # escaping ``to_path``. It is the default in Python 3.14 and available since + # 3.12 (backported to 3.10.12+/3.11.4+); fall back to a manual guard otherwise. + if hasattr(tarfile, "data_filter"): + tar.extractall(to_path, filter="data") + else: + _reject_escaping_tar_members(tar, to_path) + tar.extractall(to_path) _ZIP_COMPRESSION_MAP: dict[str, int] = {