Skip to content

Commit 589e590

Browse files
theCyberTechclaude
andcommitted
Fix symlink path traversal in skill cache extraction
`_safe_extractall` in `crewai.experimental.skills.cache` (the Python < 3.12 fallback used by `SkillCacheManager.store` to unpack registry-downloaded skill tarballs into `~/.crewai/skills/`) validated each member's *name* against the destination but never validated symlink/hardlink *targets*. A malicious skill archive 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. This is the same bug fixed in 32d2da1 for the CLI's `_safe_extractall` (`crewai_cli.experimental.skills.main`); the duplicate copy in the core library's skill cache was missed. 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 32d2da1 commit 589e590

2 files changed

Lines changed: 136 additions & 1 deletion

File tree

lib/crewai/src/crewai/experimental/skills/cache.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from datetime import datetime, timezone
1010
import json
1111
import logging
12+
import os
1213
from pathlib import Path
1314
import tarfile
1415
from typing import TypedDict
@@ -127,12 +128,38 @@ def invalidate(self, org: str, name: str) -> bool:
127128

128129

129130
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
130-
"""Path-traversal-safe extraction for Python < 3.12."""
131+
"""Path-traversal-safe extraction for Python < 3.12.
132+
133+
Validates both the member's own path and, for symlink/hardlink members,
134+
the link target. Without the link-target check a malicious archive can
135+
plant a symlink that escapes ``dest`` (e.g. ``link -> /home/user/.ssh``)
136+
followed by a regular member written *through* that link
137+
(``link/authorized_keys``), escaping ``dest`` even though every member
138+
name resolves inside it. This mirrors the protection that
139+
``tarfile.extractall(..., filter="data")`` provides on Python >= 3.12.
140+
"""
131141
dest_resolved = dest.resolve()
132142
for member in tf.getmembers():
133143
member_path = (dest / member.name).resolve()
134144
if not member_path.is_relative_to(dest_resolved):
135145
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
146+
if member.issym() or member.islnk():
147+
link_target = member.linkname
148+
# Absolute link targets always escape the destination.
149+
if os.path.isabs(link_target):
150+
raise ValueError(
151+
f"Blocked link target escaping destination: "
152+
f"{member.name!r} -> {link_target!r}"
153+
)
154+
# Hardlink names are relative to the archive root; symlink
155+
# targets are relative to the member's own directory.
156+
anchor = dest if member.islnk() else (dest / member.name).parent
157+
resolved_target = (anchor / link_target).resolve()
158+
if not resolved_target.is_relative_to(dest_resolved):
159+
raise ValueError(
160+
f"Blocked link target escaping destination: "
161+
f"{member.name!r} -> {link_target!r}"
162+
)
136163
tf.extractall(dest) # noqa: S202
137164

138165

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

0 commit comments

Comments
 (0)