get: reject copy destinations that resolve above the target - #2103
get: reject copy destinations that resolve above the target#2103Nexory wants to merge 4 commits into
Conversation
get(recursive=True) builds each local destination by joining a name from the source listing onto the destination root, then writes it. A name that carries ".." segments resolves above that root, so the copy lands outside the directory the caller asked for. With enough segments the location is arbitrary. Nothing between other_paths and get_file compares the result to the root. Check the built destinations against the root before copying, in both the sync and the async path, and only when the destination is a single string: when the caller passes a list they named every destination themselves. ".." that stays inside the root is unaffected, which keeps names such as "a/b/../inner.txt" working.
|
I suppose you should also do get_file() in that case? |
|
I looked at that before answering, and I think I checked every Where the guard genuinely does not reach is one level up, in the classes that override
If you would rather have one place that covers The branch rebases cleanly onto current master, which has moved four commits since I opened this, |
fallenmi
left a comment
There was a problem hiding this comment.
Approved after an independent exact-head check.
I reproduced the defect on the exact PR base 669de5e: a tar listing with readme.txt and ../escaped.txt completed normally and wrote escaped.txt above the requested destination. On exact head bb4d3e0, the same call raises ValueError before either file is copied.
The new generated archive cases passed across the available zip/tar variants, and the complete touched archive plus async-wrapper test files finished with 112 passed and 20 optional-backend skips. I also merged this exact head locally into current master 9b7cd48; it merged cleanly, repeated the same 112/20 result, and fsspec/tests/test_utils.py passed 98/98. A direct containment matrix covered equality, nested paths, an internal .., sibling-prefix collisions, parent escapes, and filesystem root.
The guard is placed at the right boundary: after source-controlled names become local destinations and before directory creation or copying. Explicit destination lists remain caller-owned.
Disclosure: OpenAI Codex assisted with this independent review and the account owner authorized its publication.
| try: | ||
| fs.get("*", str(dest), recursive=True) | ||
| except ValueError: | ||
| pass |
There was a problem hiding this comment.
Should use pytest.raises like the other tests, no?
The test used try/except around the call and then asserted that nothing was written above the destination. Since the guard now always raises, the except branch was dead and the neighbouring test covered the raise on its own. Fold the two into one that does both.
The only cover for check_contained was through fs.get(), which leaves its own edge cases untested: removing the separator from the prefix, so that a sibling directory whose name starts with the destination counts as inside, passes the whole suite. Add a small table over equality, nested paths, an internal "..", a parent escape and that sibling case.
|
Done. The two tests had the same fixture and the same archive, so I folded them with scenario.provider(data) as archive:
fs = fsspec.filesystem(scenario.protocol, fo=archive)
with pytest.raises(ValueError, match="outside the destination"):
fs.get("*", str(dest), recursive=True)
assert not outside.exists(), f"copy wrote {outside}, above {dest}"The While checking that the suite would actually notice a broken guard, I found that So there is a second commit with a small table over equality, nested paths, an
The rebase offer stands: the branch still applies cleanly to current master, and |
…-destinations # Conflicts: # fsspec/tests/test_utils.py
|
A correction to my last comment. When I wrote that the branch still applied I have merged current master into the branch rather than rebasing it, so the Measured on the merged branch and on an untouched checkout of master, same image One note on lint, because it briefly looked as though this change had introduced |
Summary
get(rpath, lpath, recursive=True)builds each local destination by joining a name from thesource listing onto
lpath, then writes it. A name carrying..segments resolves above thatroot, so the copy lands outside the directory the caller asked for. Nothing between
other_pathsandget_filecompares the result against the root.A tar or zip whose member list is
["readme.txt", "../escaped.txt"]putsreadme.txtin thedestination and
escaped.txtone level above it. With more segments the location moves furtherout: a member named
../../../../escaped.txtcopied into a destination four levels down lands atthe filesystem root. There is no exception, no warning, and the call returns normally, so a caller
has nothing to check.
The async path in
AsyncFileSystem._gethas the same shape and behaves the same way, which isthe path
s3fs,gcsfs,adlfsandHTTPFileSystemtake.This is the same class as #2047, one surface further along. That one hardened
DirFileSystem._joinon the source side; the copy path still builds destinations by string arithmetic.
The change
A helper in
fsspec/utils.pycompares each built destination against the destination root andraises
ValueErrorif it resolves outside, called fromAbstractFileSystem.getandAsyncFileSystem._getright afterother_paths, and only whenlpathis a single string. Whenthe caller passes a list, they named every destination themselves and the guard stays out of the
way.
On the ".." objection from #2047
The concern raised there was that
..is a legitimate path part on many filesystems and onlymeans something on
LocalFileSystem, so_joinended up narrowing its guard to that case. Thischeck does not need the same narrowing, because it sits on the destination side, and the
destination of
getis a real local directory tree by definition.copying.rststates theintent directly: the copy functions are meant to behave as POSIX
cpdoes.A
..that stays inside the root is still a plain name.a/b/../inner.txtlands atdest/a/inner.txtboth before and after the change, and there is a test for it.Testing
Three cases were added to
TestAnyArchive, so they run across zip, tar, tar.gz, tar.bz2, tar.xzand libarchive, plus one in
test_asyn_wrapper.pyfor the async path. Two of them state theproperty (nothing above the destination) and the contract (
ValueError); the third is thecontrol that
..inside the destination keeps working.Measured in a conda environment built from
ci/environment-linux.ymlwith the 3.14 matrix entryand
pip install -e .[test_full],CIRUN=true, so the s3fs, gcsfs, adlfs, pyarrow, pandas, dask,zarr, fastparquet, kerchunk, panel, paramiko, smbprotocol and libarchive suites all ran:
The set of failing tests is identical in both runs, so there is no regression. Those five are
test_reference.pyparquet cases that already fail on an untouched tree in this environment.One aside, in case it turns up in a CI run:
test_cached.py::test_clear_expiredis timingdependent here. Run on its own it failed once in six attempts with the change and once in six
without it, so it does not look related to this patch, but I would rather mention it than have it
appear unexplained.
Before the change the new cases give 13 failed, 6 passed, the six being the control across the
archive scenarios. The first of them reports the substance rather than a missing exception:
ruff check fsspec/reports the same findings with and without the change.Neighbours I did not touch
putandcopybuild their destinations the same way, but their target is a remote filesystemwhere
..is an ordinary name part, which is the situation #2047 discussed. They seem to want adifferent answer and are left alone here.
ReferenceFileSystem.getoverridesgetand callsother_pathsitself, so this change does notreach it. Its listing does carry
..keys throughexpand_path, but in my setup that copy wrotenothing at all, including the benign file, so I have no working control there and make no claim
about it. Happy to look again if it is worth a separate issue.
I did not add a changelog entry, since recent merged pull requests do not seem to carry one. Say
the word and I will add a line.