Skip to content

Commit b63efef

Browse files
committed
Review comments
1 parent fbca9ea commit b63efef

4 files changed

Lines changed: 38 additions & 8 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Release 0.14.0 (unreleased)
1515
* Fix ``dfetch add`` crashing with a ``ValueError`` when the remote URL has a trailing slash (#1137)
1616
* Fix unhelpful error message when a metadata file is malformed (#1145)
1717
* Fix arbitrary file write via malicious tar/zip symlink (#1152)
18-
* Prevent ssh command injection (#1152)
18+
* Prevent SSH command injection (#1152)
1919

2020
Release 0.13.0 (released 2026-03-30)
2121
====================================

dfetch/vcs/archive.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -510,15 +510,11 @@ def _check_no_member_traverses_symlink(members: list[tarfile.TarInfo]) -> None:
510510
and provides defence-in-depth on newer ones.
511511
512512
Raises:
513-
RuntimeError: When a non-symlink member's path passes through a
514-
symlink member name.
513+
RuntimeError: When a member's path passes through a symlink member
514+
name, including symlink-through-symlink chains.
515515
"""
516-
symlink_names = {m.name for m in members if m.issym()}
517-
if not symlink_names:
518-
return
516+
symlink_names: set[str] = set()
519517
for member in members:
520-
if member.issym():
521-
continue
522518
parts = pathlib.PurePosixPath(member.name).parts
523519
for depth in range(1, len(parts)):
524520
parent = str(pathlib.PurePosixPath(*parts[:depth]))
@@ -527,6 +523,8 @@ def _check_no_member_traverses_symlink(members: list[tarfile.TarInfo]) -> None:
527523
f"Archive member {member.name!r} would be written "
528524
f"through symlink {parent!r}"
529525
)
526+
if member.issym():
527+
symlink_names.add(str(pathlib.PurePosixPath(member.name)))
530528

531529
@staticmethod
532530
def _check_tar_members(tf: tarfile.TarFile) -> None:

tests/test_archive.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,33 @@ def _setup(tf: tarfile.TarFile) -> None:
358358
_check_tar_members(tf)
359359

360360

361+
def test_check_tar_members_rejects_two_step_symlink_attack_dotprefix():
362+
"""Two-step tar-slip via a ./ prefixed symlink name must be rejected."""
363+
364+
def _setup(tf: tarfile.TarFile) -> None:
365+
_add_symlink(tf, "./project/link", "../../outside")
366+
content = b"escaped"
367+
info = tarfile.TarInfo("project/link/payload.txt")
368+
info.size = len(content)
369+
tf.addfile(info, io.BytesIO(content))
370+
371+
tf = _make_tar_with_member(_setup)
372+
with pytest.raises(RuntimeError, match="symlink"):
373+
_check_tar_members(tf)
374+
375+
376+
def test_check_tar_members_rejects_symlink_chain():
377+
"""A second symlink whose path passes through an earlier symlink must be rejected."""
378+
379+
def _setup(tf: tarfile.TarFile) -> None:
380+
_add_symlink(tf, "project/link", "../../outside")
381+
_add_symlink(tf, "project/link/evil", "../payload")
382+
383+
tf = _make_tar_with_member(_setup)
384+
with pytest.raises(RuntimeError, match="symlink"):
385+
_check_tar_members(tf)
386+
387+
361388
def test_check_tar_members_allows_file_next_to_symlink():
362389
"""A file sibling to a symlink (not written through it) must be accepted."""
363390

tests/test_git_vcs.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,8 @@ def test_build_git_ssh_command(name, env_ssh, git_config_ssh, expected):
271271
mock_logger.debug.assert_called_once()
272272
else:
273273
mock_logger.debug.assert_not_called()
274+
275+
if "injection" in name:
276+
mock_logger.warning.assert_called()
277+
else:
278+
mock_logger.warning.assert_not_called()

0 commit comments

Comments
 (0)