Skip to content

Commit 32d2da1

Browse files
theCyberTechclaude
andcommitted
Fix symlink path traversal in skill archive extraction
`_safe_extractall` (the Python < 3.12 fallback used by `crewai skills` archive unpacking) validated each member's *name* against the destination but never validated symlink/hardlink *targets*. A malicious skill tarball could plant a symlink escaping the destination (e.g. `link -> /home/user/.ssh`) followed by a regular member written through it (`link/authorized_keys`), escaping `dest` even though every member name resolves inside it — the classic symlink-extraction traversal. The 3.12+ path (`extractall(..., filter="data")`) already blocks this; the fallback now mirrors it by rejecting absolute link targets and any link target that resolves outside the destination directory. Adds regression tests covering absolute and relative escaping symlinks plus benign in-tree symlinks and ordinary archives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7bb9bc7 commit 32d2da1

2 files changed

Lines changed: 134 additions & 1 deletion

File tree

lib/cli/src/crewai_cli/experimental/skills/main.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,12 +378,38 @@ def _read_version(self, skill_md: Path) -> str | None:
378378

379379

380380
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
381-
"""Path-traversal-safe extraction for Python < 3.12."""
381+
"""Path-traversal-safe extraction for Python < 3.12.
382+
383+
Validates both the member's own path and, for symlink/hardlink members,
384+
the link target. Without the link-target check a malicious archive can
385+
plant a symlink that escapes ``dest`` (e.g. ``link -> /home/user/.ssh``)
386+
followed by a regular member written *through* that link
387+
(``link/authorized_keys``), escaping ``dest`` even though every member
388+
name resolves inside it. This mirrors the protection that
389+
``tarfile.extractall(..., filter="data")`` provides on Python >= 3.12.
390+
"""
382391
dest_resolved = dest.resolve()
383392
for member in tf.getmembers():
384393
member_path = (dest / member.name).resolve()
385394
if not member_path.is_relative_to(dest_resolved):
386395
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
396+
if member.issym() or member.islnk():
397+
link_target = member.linkname
398+
# Absolute link targets always escape the destination.
399+
if os.path.isabs(link_target):
400+
raise ValueError(
401+
f"Blocked link target escaping destination: "
402+
f"{member.name!r} -> {link_target!r}"
403+
)
404+
# Hardlink names are relative to the archive root; symlink
405+
# targets are relative to the member's own directory.
406+
anchor = dest if member.islnk() else (dest / member.name).parent
407+
resolved_target = (anchor / link_target).resolve()
408+
if not resolved_target.is_relative_to(dest_resolved):
409+
raise ValueError(
410+
f"Blocked link target escaping destination: "
411+
f"{member.name!r} -> {link_target!r}"
412+
)
387413
tf.extractall(dest) # noqa: S202
388414

389415

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Regression tests for path-traversal-safe archive extraction.
2+
3+
Guards against symlink/hardlink-based path traversal in the Python < 3.12
4+
extraction fallback (`_safe_extractall`). The 3.12+ path relies on
5+
`tarfile.extractall(..., filter="data")`; the fallback must provide the same
6+
protection by validating link targets, not just member names.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import io
12+
import tarfile
13+
from pathlib import Path
14+
15+
import pytest
16+
17+
from crewai_cli.experimental.skills.main import _safe_extractall
18+
19+
20+
def _tar_from_members(build) -> tarfile.TarFile:
21+
"""Build an in-memory tar archive via `build(tf)` and return it for reading."""
22+
buf = io.BytesIO()
23+
with tarfile.open(fileobj=buf, mode="w") as tf:
24+
build(tf)
25+
buf.seek(0)
26+
return tarfile.open(fileobj=buf, mode="r")
27+
28+
29+
def test_blocks_symlink_escaping_destination(tmp_path: Path) -> None:
30+
"""A symlink whose target escapes dest, plus a file written through it,
31+
must be rejected before anything is extracted."""
32+
outside = tmp_path / "outside"
33+
outside.mkdir()
34+
dest = tmp_path / "dest"
35+
dest.mkdir()
36+
37+
def build(tf: tarfile.TarFile) -> None:
38+
link = tarfile.TarInfo("link")
39+
link.type = tarfile.SYMTYPE
40+
link.linkname = str(outside) # absolute path outside dest
41+
tf.addfile(link)
42+
payload = b"pwned"
43+
info = tarfile.TarInfo("link/evil.txt")
44+
info.size = len(payload)
45+
tf.addfile(info, io.BytesIO(payload))
46+
47+
with _tar_from_members(build) as tf:
48+
with pytest.raises(ValueError, match="escaping destination"):
49+
_safe_extractall(tf, dest)
50+
51+
assert not (outside / "evil.txt").exists()
52+
53+
54+
def test_blocks_relative_symlink_escaping_destination(tmp_path: Path) -> None:
55+
"""A relative symlink (../..) that escapes dest is also rejected."""
56+
dest = tmp_path / "dest"
57+
dest.mkdir()
58+
59+
def build(tf: tarfile.TarFile) -> None:
60+
link = tarfile.TarInfo("sub/link")
61+
link.type = tarfile.SYMTYPE
62+
link.linkname = "../../outside" # escapes dest from sub/
63+
tf.addfile(link)
64+
65+
with _tar_from_members(build) as tf:
66+
with pytest.raises(ValueError, match="escaping destination"):
67+
_safe_extractall(tf, dest)
68+
69+
70+
def test_allows_benign_relative_symlink(tmp_path: Path) -> None:
71+
"""A symlink that stays within dest is permitted."""
72+
dest = tmp_path / "dest"
73+
dest.mkdir()
74+
75+
def build(tf: tarfile.TarFile) -> None:
76+
payload = b"hi"
77+
info = tarfile.TarInfo("real.txt")
78+
info.size = len(payload)
79+
tf.addfile(info, io.BytesIO(payload))
80+
link = tarfile.TarInfo("alias.txt")
81+
link.type = tarfile.SYMTYPE
82+
link.linkname = "real.txt" # stays inside dest
83+
tf.addfile(link)
84+
85+
with _tar_from_members(build) as tf:
86+
_safe_extractall(tf, dest)
87+
88+
assert (dest / "real.txt").read_bytes() == b"hi"
89+
90+
91+
def test_allows_benign_archive(tmp_path: Path) -> None:
92+
"""An ordinary archive of regular files extracts correctly."""
93+
dest = tmp_path / "dest"
94+
dest.mkdir()
95+
96+
def build(tf: tarfile.TarFile) -> None:
97+
for name, body in (("SKILL.md", b"# skill"), ("scripts/run.py", b"print(1)")):
98+
payload = body
99+
info = tarfile.TarInfo(name)
100+
info.size = len(payload)
101+
tf.addfile(info, io.BytesIO(payload))
102+
103+
with _tar_from_members(build) as tf:
104+
_safe_extractall(tf, dest)
105+
106+
assert (dest / "SKILL.md").read_bytes() == b"# skill"
107+
assert (dest / "scripts" / "run.py").read_bytes() == b"print(1)"

0 commit comments

Comments
 (0)