From 2073b7cc6e6774a74accfee301745556b26bae49 Mon Sep 17 00:00:00 2001 From: sahvx655-wq Date: Sun, 14 Jun 2026 13:47:35 +0530 Subject: [PATCH 1/2] reject paths that escape the root in DirFileSystem._join --- fsspec/implementations/dirfs.py | 20 +++++++++++++++++- fsspec/implementations/tests/test_dirfs.py | 24 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/fsspec/implementations/dirfs.py b/fsspec/implementations/dirfs.py index 65b9b5da1..e8a1e509a 100644 --- a/fsspec/implementations/dirfs.py +++ b/fsspec/implementations/dirfs.py @@ -3,6 +3,19 @@ from .chained import ChainedFileSystem +def _escapes_root(path): + """Whether a relative path would resolve above its root via ".." segments.""" + depth = 0 + for part in path.split("/"): + if part == "..": + depth -= 1 + if depth < 0: + return True + elif part and part != ".": + depth += 1 + return False + + class DirFileSystem(AsyncFileSystem, ChainedFileSystem): """Directory prefix filesystem @@ -54,7 +67,12 @@ def _join(self, path): return path if not path: return self.path - return self.fs.sep.join((self.path, self._strip_protocol(path))) + path = self._strip_protocol(path) + if _escapes_root(path): + raise ValueError( + f"path {path!r} escapes the {self.path!r} root of the filesystem" + ) + return self.fs.sep.join((self.path, path)) if isinstance(path, dict): return {self._join(_path): value for _path, value in path.items()} return [self._join(_path) for _path in path] diff --git a/fsspec/implementations/tests/test_dirfs.py b/fsspec/implementations/tests/test_dirfs.py index 7f963c0ee..041f96006 100644 --- a/fsspec/implementations/tests/test_dirfs.py +++ b/fsspec/implementations/tests/test_dirfs.py @@ -104,6 +104,30 @@ def test_path_no_leading_slash(fs, root, rel, full): assert dirfs._relpath(full) == rel +@pytest.mark.parametrize( + "path", + ["..", "../", "../secret", "foo/../..", "foo/../../secret", "/../secret"], +) +def test_join_rejects_escape(fs, path): + dirfs = DirFileSystem("root", fs) + with pytest.raises(ValueError): + dirfs._join(path) + + +@pytest.mark.parametrize( + "path, full", + [ + ("foo", "root/foo"), + ("foo/bar", "root/foo/bar"), + ("./foo", "root/./foo"), + ("foo/../bar", "root/foo/../bar"), + ], +) +def test_join_allows_internal(fs, path, full): + dirfs = DirFileSystem("root", fs) + assert dirfs._join(path) == full + + def test_sep(mocker, dirfs): sep = mocker.Mock() dirfs.fs.sep = sep From 8185fbedaafaf7e4873ee2101f0fc6148d6c856d Mon Sep 17 00:00:00 2001 From: sahvx655-wq Date: Mon, 22 Jun 2026 19:33:19 +0530 Subject: [PATCH 2/2] dirfs: only enforce root escape check on local filesystems --- fsspec/implementations/dirfs.py | 6 ++++- fsspec/implementations/tests/test_dirfs.py | 27 +++++++++++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/fsspec/implementations/dirfs.py b/fsspec/implementations/dirfs.py index e8a1e509a..0fe1ababf 100644 --- a/fsspec/implementations/dirfs.py +++ b/fsspec/implementations/dirfs.py @@ -1,6 +1,7 @@ from .. import filesystem from ..asyn import AsyncFileSystem from .chained import ChainedFileSystem +from .local import LocalFileSystem def _escapes_root(path): @@ -68,7 +69,10 @@ def _join(self, path): if not path: return self.path path = self._strip_protocol(path) - if _escapes_root(path): + # ".." only navigates above the root on filesystems that resolve it + # against a real directory tree; on object stores it is a literal + # path part, so only guard the local case here. + if isinstance(self.fs, LocalFileSystem) and _escapes_root(path): raise ValueError( f"path {path!r} escapes the {self.path!r} root of the filesystem" ) diff --git a/fsspec/implementations/tests/test_dirfs.py b/fsspec/implementations/tests/test_dirfs.py index 041f96006..fd53134b6 100644 --- a/fsspec/implementations/tests/test_dirfs.py +++ b/fsspec/implementations/tests/test_dirfs.py @@ -2,6 +2,7 @@ from fsspec.asyn import AsyncFileSystem from fsspec.implementations.dirfs import DirFileSystem +from fsspec.implementations.local import LocalFileSystem from fsspec.spec import AbstractFileSystem PATH = "path/to/dir" @@ -108,24 +109,24 @@ def test_path_no_leading_slash(fs, root, rel, full): "path", ["..", "../", "../secret", "foo/../..", "foo/../../secret", "/../secret"], ) -def test_join_rejects_escape(fs, path): - dirfs = DirFileSystem("root", fs) +def test_join_rejects_escape_local(tmp_path, path): + dirfs = DirFileSystem(str(tmp_path), LocalFileSystem()) with pytest.raises(ValueError): dirfs._join(path) -@pytest.mark.parametrize( - "path, full", - [ - ("foo", "root/foo"), - ("foo/bar", "root/foo/bar"), - ("./foo", "root/./foo"), - ("foo/../bar", "root/foo/../bar"), - ], -) -def test_join_allows_internal(fs, path, full): +@pytest.mark.parametrize("path", ["foo", "foo/bar", "foo/../bar"]) +def test_join_allows_internal_local(tmp_path, path): + dirfs = DirFileSystem(str(tmp_path), LocalFileSystem()) + assert dirfs._join(path) == f"{dirfs.path}/{path}" + + +@pytest.mark.parametrize("path", ["../secret", "foo/../../secret", ".."]) +def test_join_keeps_dotdot_for_non_local(fs, path): + # ".." is only special on local-style filesystems; elsewhere it is a + # legitimate path part and must not be rejected. dirfs = DirFileSystem("root", fs) - assert dirfs._join(path) == full + assert dirfs._join(path) == f"root/{path}" def test_sep(mocker, dirfs):