Skip to content

Commit 0cc0c5e

Browse files
reference: keep get destinations inside the target
Prevent reference-controlled paths from escaping the caller-selected local destination, while preserving paths that normalize within it. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b9cf257 commit 0cc0c5e

2 files changed

Lines changed: 44 additions & 0 deletions

File tree

fsspec/implementations/reference.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from fsspec.core import filesystem, open, split_protocol
2424
from fsspec.implementations.asyn_wrapper import AsyncFileSystemWrapper
2525
from fsspec.utils import (
26+
check_contained,
2627
isfilelike,
2728
merge_offset_ranges,
2829
other_paths,
@@ -882,6 +883,11 @@ def get(self, rpath, lpath, recursive=False, **kwargs):
882883
rpath = self.expand_path(rpath, recursive=recursive)
883884
fs = fsspec.filesystem("file", auto_mkdir=True)
884885
targets = other_paths(rpath, lpath)
886+
if isinstance(lpath, str):
887+
# The names came from the source listing; ".." in one of them
888+
# would otherwise place the copy above the destination. When
889+
# lpath is a list the caller named every destination itself.
890+
check_contained(lpath, targets)
885891
if recursive:
886892
data = self.cat([r for r in rpath if not self.isdir(r)])
887893
else:

fsspec/implementations/tests/test_reference.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,44 @@ def test_get_sync(tmpdir):
442442
assert (tmpdir / "c" / "d").read_binary() == b"123456"
443443

444444

445+
def test_get_does_not_write_above_destination(tmp_path):
446+
# Reference keys come from the spec, so a key holding ".." must not
447+
# place the copy above the destination the caller asked for.
448+
dest = tmp_path / "dest"
449+
dest.mkdir()
450+
outside = tmp_path / "escaped.txt"
451+
outside.write_bytes(b"ORIGINAL")
452+
453+
refs = {
454+
"version": 1,
455+
"refs": {
456+
"dataset/../escaped.txt": b"ATTACKER",
457+
"dataset/normal.txt": b"ok",
458+
},
459+
}
460+
fs = fsspec.filesystem("reference", fo=refs, skip_instance_cache=True)
461+
with pytest.raises(ValueError, match="outside the destination"):
462+
fs.get("dataset", str(dest) + "/", recursive=True)
463+
464+
assert outside.read_bytes() == b"ORIGINAL"
465+
assert not (dest / "normal.txt").exists()
466+
467+
468+
def test_get_keeps_dotdot_inside_destination(tmp_path):
469+
# ".." that resolves within the destination stays a legitimate name.
470+
dest = tmp_path / "dest"
471+
dest.mkdir()
472+
refs = {
473+
"plain.txt": b"ok",
474+
"a/b/../inner.txt": b"inner",
475+
}
476+
fs = fsspec.filesystem("reference", fo=refs, skip_instance_cache=True)
477+
fs.get("", str(dest) + "/", recursive=True)
478+
479+
assert (dest / "plain.txt").read_bytes() == b"ok"
480+
assert (dest / "a" / "inner.txt").read_bytes() == b"inner"
481+
482+
445483
def test_multi_fs_provided(m, tmpdir):
446484
localfs = LocalFileSystem()
447485

0 commit comments

Comments
 (0)