Skip to content

Commit 942ebf5

Browse files
committed
Don't reject all .. symlinks i
Fixes #1122
1 parent 04569c9 commit 942ebf5

5 files changed

Lines changed: 147 additions & 21 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Release 0.14.0 (unreleased)
1010
* Allow ``dfetch freeze`` to accept project names to freeze only specific projects (#1063)
1111
* Edit manifest in-place when freezing inside a git or SVN superproject, preserving comments and layout (#1063)
1212
* Add new ``remove`` command to remove projects from manifest and disk (#26)
13+
* Fix "unsafe symlink target" error for archives containing relative ``..`` symlinks (#1122)
1314

1415
Release 0.13.0 (released 2026-03-30)
1516
====================================

dfetch/vcs/archive.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444

4545
from dfetch.log import get_logger
4646
from dfetch.util.util import (
47+
check_no_path_traversal,
4748
copy_directory_contents,
4849
copy_src_subset,
4950
prune_files_by_pattern,
@@ -325,6 +326,8 @@ def extract(
325326
else:
326327
copy_directory_contents(extract_root, dest_dir)
327328

329+
ArchiveLocalRepo._check_symlinks_in_dest(dest_dir)
330+
328331
if ignore:
329332
prune_files_by_pattern(dest_dir, ignore)
330333

@@ -384,20 +387,38 @@ def check_zip_members(zf: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
384387

385388
@staticmethod
386389
def _is_unsafe_symlink_target(target: str) -> bool:
387-
r"""Return *True* when *target* is an unsafe symlink destination.
390+
r"""Return *True* when *target* is an absolute symlink destination.
388391
389-
A target is unsafe if it is absolute or contains ``..`` components
390-
under either POSIX or Windows path semantics, so that backslash-based
391-
traversals like ``..\\..\\evil`` are caught on any host OS.
392+
Absolute targets (POSIX ``/`` or Windows drive/UNC anchors) are
393+
always unsafe regardless of context. Relative targets that contain
394+
``..`` components are NOT rejected here; they are validated after
395+
extraction by :meth:`_check_symlinks_in_dest` using the manifest
396+
root as the safety boundary.
392397
"""
393398
posix = pathlib.PurePosixPath(target)
394399
win = pathlib.PureWindowsPath(target)
395-
return (
396-
posix.is_absolute()
397-
or bool(win.anchor)
398-
or any(part == ".." for part in posix.parts)
399-
or any(part == ".." for part in win.parts)
400-
)
400+
return posix.is_absolute() or bool(win.anchor)
401+
402+
@staticmethod
403+
def _check_symlinks_in_dest(dest_dir: str) -> None:
404+
"""Raise *RuntimeError* if any symlink in *dest_dir* escapes the manifest root.
405+
406+
Walks *dest_dir* without following symlinks and calls
407+
:func:`~dfetch.util.util.check_no_path_traversal` for every symlink
408+
found. Because :func:`os.path.realpath` follows the link to its
409+
target, this catches any symlink—including deeply nested ones—whose
410+
resolved destination lies outside the current working directory
411+
(the manifest root, as set by :func:`~dfetch.util.util.in_directory`).
412+
413+
Raises:
414+
RuntimeError: When a symlink resolves to a path outside the manifest root.
415+
"""
416+
manifest_root = os.getcwd()
417+
for root, dirs, files in os.walk(dest_dir, followlinks=False):
418+
for name in dirs + files:
419+
path = os.path.join(root, name)
420+
if os.path.islink(path):
421+
check_no_path_traversal(path, manifest_root)
401422

402423
@staticmethod
403424
def _check_zip_member_type(info: zipfile.ZipInfo, zf: zipfile.ZipFile) -> None:

features/fetch-archive.feature

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,33 @@ Feature: Fetching dependencies from an archive (tar/zip)
224224
| path |
225225
| MyProject/LibA |
226226
| MyProject/LibB |
227+
228+
Scenario: Archive with internal relative symlink using .. is fetched safely
229+
Given an archive "SomeProject.tar.gz" with the files
230+
| path |
231+
| README.md |
232+
| other/target.mk |
233+
And the archive "SomeProject.tar.gz" contains a symlink "sub/dir/link.mk" pointing to "../../other/target.mk"
234+
And the manifest 'dfetch.yaml' in MyProject
235+
"""
236+
manifest:
237+
version: '0.0'
238+
projects:
239+
- name: SomeProject
240+
url: some-remote-server/SomeProject.tar.gz
241+
vcs: archive
242+
"""
243+
When I run "dfetch update" in MyProject
244+
Then 'MyProject' looks like:
245+
"""
246+
MyProject/
247+
SomeProject/
248+
.dfetch_data.yaml
249+
README.md
250+
other/
251+
target.mk
252+
sub/
253+
dir/
254+
link.mk
255+
dfetch.yaml
256+
"""

features/steps/archive_steps.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ def create_zip(archive_path: str, name: str, files: list[dict]) -> None:
7070
zf.writestr(member_path, content)
7171

7272

73+
def add_symlink_to_tar_gz(
74+
archive_path: str, name: str, symlink_path: str, symlink_target: str
75+
) -> None:
76+
"""Append a symlink member to an existing .tar.gz archive.
77+
78+
Rewrites the archive because gzip does not support append mode in Python's tarfile.
79+
"""
80+
existing: list[tuple[tarfile.TarInfo, bytes | None]] = []
81+
with tarfile.open(archive_path, "r:gz") as tar:
82+
for member in tar.getmembers():
83+
fobj = tar.extractfile(member)
84+
existing.append((member, fobj.read() if fobj else None))
85+
86+
with tarfile.open(archive_path, "w:gz") as tar:
87+
for member, data in existing:
88+
tar.addfile(member, io.BytesIO(data) if data is not None else None)
89+
info = tarfile.TarInfo(name=f"{name}/{symlink_path}")
90+
info.type = tarfile.SYMTYPE
91+
info.linkname = symlink_target
92+
tar.addfile(info)
93+
94+
7395
def _archive_url(context, filename: str) -> str:
7496
"""Build the archive URL in the same format used by apply_manifest_substitutions.
7597
@@ -139,3 +161,15 @@ def step_impl(context, name, license_id):
139161
info.size = len(content_bytes)
140162
tar.addfile(info, io.BytesIO(content_bytes))
141163
context.archive_url = _archive_url(context, f"{name}.tar.gz")
164+
165+
166+
@given(
167+
'the archive "{name}.tar.gz" contains a symlink "{symlink_path}" pointing to "{symlink_target}"'
168+
)
169+
def step_impl(context, name, symlink_path, symlink_target):
170+
server_path = context.remotes_dir_path
171+
archive_path = os.path.join(server_path, f"{name}.tar.gz")
172+
add_symlink_to_tar_gz(archive_path, name, symlink_path, symlink_target)
173+
context.archive_sha256 = _file_digest(archive_path, hashlib.sha256)
174+
context.archive_sha384 = _file_digest(archive_path, hashlib.sha384)
175+
context.archive_sha512 = _file_digest(archive_path, hashlib.sha512)

tests/test_archive.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -156,17 +156,15 @@ def test_check_zip_members_symlink_absolute_target():
156156

157157

158158
def test_check_zip_members_symlink_dotdot_target():
159-
"""ZIP symlink pointing outside extraction dir via .. must be rejected."""
159+
"""ZIP symlink with .. is allowed at pre-extraction time (post-copy check enforces boundary)."""
160160
zf = _make_zip_with_symlink("link", "../../etc/shadow")
161-
with pytest.raises(RuntimeError, match="symlink"):
162-
_check_zip_members(zf)
161+
_check_zip_members(zf) # must NOT raise
163162

164163

165164
def test_check_zip_members_symlink_windows_dotdot_target():
166-
"""ZIP symlink with Windows-style backslash traversal must be rejected."""
165+
"""ZIP symlink with Windows-style backslash .. is allowed at pre-extraction time."""
167166
zf = _make_zip_with_symlink("link", "..\\..\\evil")
168-
with pytest.raises(RuntimeError, match="symlink"):
169-
_check_zip_members(zf)
167+
_check_zip_members(zf) # must NOT raise
170168

171169

172170
def test_check_zip_members_symlink_safe_relative():
@@ -269,18 +267,17 @@ def test_check_tar_member_type_absolute_symlink():
269267

270268

271269
def test_check_tar_member_type_dotdot_symlink():
270+
"""Relative .. symlinks are allowed at pre-extraction time (post-copy check enforces boundary)."""
272271
tf = _make_tar_with_member(lambda t: _add_symlink(t, "link", "../../etc/passwd"))
273272
member = tf.getmembers()[0]
274-
with pytest.raises(RuntimeError, match="unsafe target"):
275-
_check_tar_member_type(member)
273+
_check_tar_member_type(member) # must NOT raise
276274

277275

278276
def test_check_tar_member_type_windows_dotdot_symlink():
279-
"""TAR symlink with Windows-style backslash traversal must be rejected."""
277+
"""Windows-style .. symlinks are allowed at pre-extraction time."""
280278
tf = _make_tar_with_member(lambda t: _add_symlink(t, "link", "..\\..\\evil"))
281279
member = tf.getmembers()[0]
282-
with pytest.raises(RuntimeError, match="unsafe target"):
283-
_check_tar_member_type(member)
280+
_check_tar_member_type(member) # must NOT raise
284281

285282

286283
# ---------------------------------------------------------------------------
@@ -346,6 +343,49 @@ def test_check_tar_members_rejects_device_file():
346343
_check_tar_members(tf)
347344

348345

346+
# ---------------------------------------------------------------------------
347+
# ArchiveLocalRepo._check_symlinks_in_dest
348+
# ---------------------------------------------------------------------------
349+
350+
351+
def test_check_symlinks_in_dest_safe_internal_dotdot(tmp_path, monkeypatch):
352+
"""A symlink with .. that stays within dest_dir must be accepted."""
353+
monkeypatch.chdir(tmp_path)
354+
dest = tmp_path / "pkg"
355+
dest.mkdir()
356+
(dest / "other").mkdir()
357+
(dest / "other" / "target.mk").write_text("content")
358+
sub = dest / "sub" / "dir"
359+
sub.mkdir(parents=True)
360+
(sub / "link.mk").symlink_to("../../other/target.mk")
361+
362+
ArchiveLocalRepo._check_symlinks_in_dest(str(dest)) # must NOT raise
363+
364+
365+
def test_check_symlinks_in_dest_escaping_symlink(tmp_path, monkeypatch):
366+
"""A symlink that resolves outside the manifest root must be rejected."""
367+
monkeypatch.chdir(tmp_path)
368+
dest = tmp_path / "pkg"
369+
dest.mkdir()
370+
(dest / "evil.txt").symlink_to("../../../etc/passwd")
371+
372+
with pytest.raises(RuntimeError):
373+
ArchiveLocalRepo._check_symlinks_in_dest(str(dest))
374+
375+
376+
def test_check_symlinks_in_dest_sibling_within_manifest(tmp_path, monkeypatch):
377+
"""A symlink pointing to a sibling project (still within manifest root) is accepted."""
378+
monkeypatch.chdir(tmp_path)
379+
sibling = tmp_path / "sibling"
380+
sibling.mkdir()
381+
(sibling / "file.txt").write_text("content")
382+
dest = tmp_path / "pkg"
383+
dest.mkdir()
384+
(dest / "link.txt").symlink_to("../sibling/file.txt")
385+
386+
ArchiveLocalRepo._check_symlinks_in_dest(str(dest)) # must NOT raise
387+
388+
349389
# ---------------------------------------------------------------------------
350390
# ArchiveRemote.is_accessible
351391
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)