Skip to content

Commit 8cc3e31

Browse files
Merge pull request #1580 from sarv-tech/fix-path-traversal
fix: Improve Path Boundary Validation in SafeTarExtractor
2 parents c16f929 + 53b2cf9 commit 8cc3e31

2 files changed

Lines changed: 87 additions & 4 deletions

File tree

security/tar_safe.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,27 @@ def _validate_member(
134134
try:
135135
target_abs = target_path.resolve()
136136
extract_abs = extract_path.resolve()
137-
if not str(target_abs).startswith(str(extract_abs)):
137+
if os.path.commonpath([str(target_abs), str(extract_abs)]) != str(extract_abs):
138138
raise UnsafeTarError(
139139
f"Path traversal detected: {member.name} -> {target_path}"
140140
)
141-
except (OSError, ValueError):
141+
except (OSError, RuntimeError):
142142
target_str = str(target_path.absolute())
143143
extract_str = str(extract_path.absolute())
144-
if not target_str.startswith(extract_str):
144+
try:
145+
if os.path.commonpath([target_str, extract_str]) != extract_str:
146+
raise UnsafeTarError(
147+
f"Path traversal detected: {member.name} -> {target_path}"
148+
)
149+
except ValueError:
145150
raise UnsafeTarError(
146-
f"Path traversal detected: {member.name} -> {target_path}"
151+
f"Path traversal detected (different drives): {member.name} -> {target_path}"
147152
)
153+
except ValueError:
154+
# os.path.commonpath raises ValueError on Windows for different drives
155+
raise UnsafeTarError(
156+
f"Path traversal detected (different drives): {member.name} -> {target_path}"
157+
)
148158

149159
if target_path.exists() and not overwrite:
150160
raise UnsafeTarError(

tests/test_security.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,79 @@ def test_path_traversal_prevention(self, tmp_path):
143143

144144
with pytest.raises(UnsafeTarError):
145145
extractor.extract(tar_file, extract_dir)
146+
147+
def test_sibling_directory_traversal(self, tmp_path):
148+
"""Test sibling directory traversal (partial path traversal)."""
149+
tar_file = tmp_path / "sibling_traversal.tar"
150+
151+
with tarfile.open(tar_file, 'w') as tar:
152+
# If extracting to 'extracted', a sibling directory could be 'extracted_evil'
153+
# Using absolute paths in tar is rare but we can simulate by making the
154+
# tar path resolve to a sibling directory when combined with the extract dir.
155+
# But SafeTarExtractor uses: extract_path / member.name
156+
# So if member.name is '../extracted_evil/file.txt', and extract_path is '/tmp/extracted'
157+
# target_path is '/tmp/extracted/../extracted_evil/file.txt' -> '/tmp/extracted_evil/file.txt'
158+
# The vulnerability was that this bypassed string prefix checking.
159+
info = tarfile.TarInfo(name='../extracted_evil/evil.txt')
160+
info.size = 10
161+
info.type = tarfile.REGTYPE
162+
163+
content = b'x' * 10
164+
fileobj = io.BytesIO(content)
165+
tar.addfile(info, fileobj)
166+
167+
extract_dir = tmp_path / "extracted"
168+
# Create the sibling directory to simulate attack scenario
169+
sibling_dir = tmp_path / "extracted_evil"
170+
sibling_dir.mkdir(exist_ok=True)
171+
172+
extractor = SafeTarExtractor()
173+
174+
with pytest.raises(UnsafeTarError):
175+
extractor.extract(tar_file, extract_dir)
176+
177+
def test_absolute_path_traversal(self, tmp_path):
178+
"""Test absolute path traversal prevention."""
179+
tar_file = tmp_path / "absolute_traversal.tar"
180+
181+
with tarfile.open(tar_file, 'w') as tar:
182+
# Create a TarInfo with an absolute path
183+
# Need to use a generic absolute path for testing
184+
info = tarfile.TarInfo(name='/tmp/evil_absolute.txt')
185+
info.size = 10
186+
info.type = tarfile.REGTYPE
187+
188+
content = b'x' * 10
189+
fileobj = io.BytesIO(content)
190+
tar.addfile(info, fileobj)
191+
192+
extract_dir = tmp_path / "extracted"
193+
extractor = SafeTarExtractor(allow_absolute_paths=False)
194+
195+
with pytest.raises(UnsafeTarError):
196+
extractor.extract(tar_file, extract_dir)
197+
198+
def test_nested_valid_directories(self, tmp_path):
199+
"""Test that nested valid directories are correctly extracted."""
200+
tar_file = tmp_path / "nested_valid.tar"
201+
202+
with tarfile.open(tar_file, 'w') as tar:
203+
# Create nested structure
204+
info = tarfile.TarInfo(name='dir1/dir2/file.txt')
205+
info.size = 10
206+
info.type = tarfile.REGTYPE
207+
208+
content = b'x' * 10
209+
fileobj = io.BytesIO(content)
210+
tar.addfile(info, fileobj)
211+
212+
extract_dir = tmp_path / "extracted"
213+
extractor = SafeTarExtractor()
214+
215+
extracted = extractor.extract(tar_file, extract_dir)
216+
217+
assert len(extracted) == 1
218+
assert (extract_dir / 'dir1' / 'dir2' / 'file.txt').exists()
146219

147220
def test_size_limits(self, tmp_path):
148221
"""Test file size limits."""

0 commit comments

Comments
 (0)