Skip to content

Commit a20cd1b

Browse files
herman5meta-codesync[bot]
authored andcommitted
Prevent URL cache path traversal
Summary: `HTTPURLHandler._get_local_path()` used URL path components when building cache destinations. Normalize URL path components with `PurePosixPath`, reject `..` traversal, and validate the resolved cache path remains under `get_cache_dir()`. Reviewed By: nponte Differential Revision: D108953882 fbshipit-source-id: 6f8b9704b0712ddbf3e1b97430448ebe0300ef75
1 parent a47d4a0 commit a20cd1b

2 files changed

Lines changed: 60 additions & 13 deletions

File tree

iopath/common/file_io.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import uuid
1414
from collections import OrderedDict
1515
from io import IOBase
16+
from pathlib import Path, PurePosixPath
1617
from types import TracebackType
1718
from typing import (
1819
Any,
@@ -917,9 +918,19 @@ def _get_local_path(
917918
):
918919
logger = logging.getLogger(__name__)
919920
parsed_url = urlparse(path)
920-
dirname = os.path.join(
921-
get_cache_dir(cache_dir), os.path.dirname(parsed_url.path.lstrip("/"))
922-
)
921+
url_path_parts = [
922+
part
923+
for part in parsed_url.path.replace("\\", "/").split("/")
924+
if part not in ("", ".")
925+
]
926+
if ".." in url_path_parts:
927+
raise ValueError(
928+
"URL path must not contain '..' components: {}".format(path)
929+
)
930+
931+
url_path = PurePosixPath(*url_path_parts)
932+
cache_root = Path(get_cache_dir(cache_dir))
933+
dirname = cache_root / url_path.parent
923934
filename = path.split("/")[-1]
924935

925936
if parsed_url.query:
@@ -928,13 +939,21 @@ def _get_local_path(
928939
if len(filename) > self.MAX_FILENAME_LEN:
929940
filename = filename[:100] + "_" + uuid.uuid4().hex
930941

931-
cached = os.path.join(dirname, filename)
932-
with file_lock(cached):
933-
if not os.path.isfile(cached):
942+
cached = dirname / filename
943+
cache_root_resolved = cache_root.resolve()
944+
cached_resolved = cached.resolve()
945+
if not cached_resolved.is_relative_to(cache_root_resolved):
946+
raise ValueError(
947+
"URL cache path must stay under cache directory: {}".format(path)
948+
)
949+
950+
cached_path = os.fspath(cached)
951+
with file_lock(cached_path):
952+
if not os.path.isfile(cached_path):
934953
logger.info("Downloading {} ...".format(path))
935-
cached = download(path, dirname, filename=filename)
936-
logger.info("URL {} cached in {}".format(path, cached))
937-
self.cache_map[path] = cached
954+
cached_path = download(path, os.fspath(dirname), filename=filename)
955+
logger.info("URL {} cached in {}".format(path, cached_path))
956+
self.cache_map[path] = cached_path
938957
return self.cache_map[path]
939958

940959
def _open(
@@ -1595,7 +1614,6 @@ def register_handler(
15951614
old_handler_type = type(self._path_handlers[prefix])
15961615
if allow_override:
15971616
# if using the global PathManager, show the warnings
1598-
global g_pathmgr
15991617
if self == g_pathmgr:
16001618
logger.warning(
16011619
f"[PathManager] Attempting to register prefix '{prefix}' from "

tests/test_file_io.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,9 @@ def test_open_read_async(self) -> None:
303303
with self.subTest("read binary"):
304304
test_data = {
305305
"test_string": "test string",
306-
1: 1,
307-
1.0: 1.0,
308-
True: True,
306+
"test_int": 1,
307+
"test_float": 1.0,
308+
"test_bool": True,
309309
}
310310
tmp_binary_path = os.path.join(self._tmpdir, "test_binary.bin") # type: ignore
311311
pickle.dump(test_data, open(tmp_binary_path, "wb"))
@@ -372,6 +372,35 @@ def test_get_local_path(self) -> None:
372372
self.assertTrue(os.path.exists(local_path))
373373
self.assertTrue(os.path.isfile(local_path))
374374

375+
def test_get_local_path_rejects_path_traversal(self) -> None:
376+
with patch.object(
377+
file_io, "get_cache_dir", return_value=self._cache_dir
378+
), patch.object(file_io, "download") as mock_download:
379+
with self.assertRaisesRegex(ValueError, "must not contain"):
380+
self._pathmgr.get_local_path(
381+
"https://example.com/models/../../escape.txt", force=True
382+
)
383+
mock_download.assert_not_called()
384+
385+
def test_get_local_path_rejects_cache_symlink_escape(self) -> None:
386+
symlink_name = "escape_" + uuid.uuid4().hex
387+
symlink_dir = os.path.join(self._cache_dir, symlink_name)
388+
with tempfile.TemporaryDirectory() as outside_dir:
389+
os.symlink(outside_dir, symlink_dir)
390+
try:
391+
with patch.object(
392+
file_io, "get_cache_dir", return_value=self._cache_dir
393+
), patch.object(file_io, "download") as mock_download:
394+
with self.assertRaisesRegex(ValueError, "cache path"):
395+
self._pathmgr.get_local_path(
396+
f"https://example.com/{symlink_name}/model.bin",
397+
force=True,
398+
)
399+
mock_download.assert_not_called()
400+
finally:
401+
if os.path.lexists(symlink_dir):
402+
os.unlink(symlink_dir)
403+
375404
def test_open(self) -> None:
376405
with self._patch_download():
377406
with self._pathmgr.open(self._remote_uri, "rb") as f:

0 commit comments

Comments
 (0)