Skip to content

Commit 5c6f6b4

Browse files
committed
fix(zip): Add path validation for symlink extraction
1 parent cde14de commit 5c6f6b4

File tree

2 files changed

+78
-2
lines changed

2 files changed

+78
-2
lines changed

samcli/local/lambdafn/zip.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ def _extract(file_info, output_dir, zip_ref):
4848
-------
4949
string
5050
Returns the target path the Zip Entry was extracted to.
51+
52+
Raises
53+
------
54+
ValueError
55+
If the extraction path would escape the output directory (path traversal attack)
5156
"""
5257

5358
# Handle any regular file/directory entries
@@ -57,6 +62,23 @@ def _extract(file_info, output_dir, zip_ref):
5762
source = zip_ref.read(file_info.filename).decode("utf8")
5863
link_name = os.path.normpath(os.path.join(output_dir, file_info.filename))
5964

65+
output_dir_abs = os.path.abspath(output_dir)
66+
link_name_abs = os.path.abspath(link_name)
67+
68+
# Ensure the resolved path is within the output directory
69+
# Check that link_name_abs starts with output_dir_abs followed by a separator
70+
if not link_name_abs.startswith(output_dir_abs + os.sep) and link_name_abs != output_dir_abs:
71+
LOG.warning(
72+
"Entry '%s' would extract to '%s' which is outside target directory '%s'.",
73+
file_info.filename,
74+
link_name_abs,
75+
output_dir_abs,
76+
)
77+
raise ValueError(
78+
f"Attempted path traversal in zip file. "
79+
f"Entry '{file_info.filename}' would extract outside target directory."
80+
)
81+
6082
# make leading dirs if needed
6183
leading_dirs = os.path.dirname(link_name)
6284
if not os.path.exists(leading_dirs):

tests/unit/local/lambdafn/test_zip.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
from tempfile import NamedTemporaryFile, mkdtemp
88
from unittest import TestCase
99
from unittest import skipIf
10-
from unittest.mock import patch
10+
from unittest.mock import patch, Mock
1111
from parameterized import parameterized, param
1212

13-
from samcli.local.lambdafn.zip import unzip, _override_permissions
13+
from samcli.local.lambdafn.zip import unzip, _override_permissions, _extract
1414

1515
# On Windows, permissions do not match 1:1 with permissions on Unix systems.
1616
SKIP_UNZIP_PERMISSION_TESTS = platform.system() == "Windows"
@@ -153,6 +153,25 @@ def _temp_dir(self):
153153
if name:
154154
shutil.rmtree(name)
155155

156+
@parameterized.expand(
157+
[
158+
param("../../etc/other", b"/etc/other"),
159+
param("../../../etc/other", b"/etc/other"),
160+
param("/etc/other", b"/etc/other"),
161+
param("foo/../../../../../../etc/other", b"target"),
162+
]
163+
)
164+
def test_unzip_blocks_symlinks_with_invalid_paths(self, symlink_path, symlink_target):
165+
"""Test that unzip blocks symlinks attempting to escape the extraction directory."""
166+
test_files = {symlink_path: {"file_type": 0o12, "contents": symlink_target, "permissions": 0o644}}
167+
168+
with self._create_zip(test_files, add_attributes=True) as zip_file_name:
169+
with self._temp_dir() as extract_dir:
170+
with self.assertRaises(ValueError) as context:
171+
unzip(zip_file_name, extract_dir)
172+
173+
self.assertIn("path traversal", str(context.exception).lower())
174+
156175

157176
class TestOverridePermissions(TestCase):
158177
@patch("samcli.local.lambdafn.zip.os")
@@ -166,3 +185,38 @@ def test_must_not_override_permissions(self, os_patch):
166185
_override_permissions(path="./home", permission=None)
167186

168187
os_patch.chmod.assert_not_called()
188+
189+
190+
class TestSymlinkExtractionValidation(TestCase):
191+
"""Tests for symlink path validation during extraction.
192+
193+
These tests verify that symlinks with paths that would escape the target
194+
directory are blocked. The key is the symlink's location (file_info.filename),
195+
not what it points to (the symlink target).
196+
"""
197+
198+
# Maps symlink paths to their targets
199+
# The path (key) is what we're validating - it attempts to escape the extraction directory
200+
# The target (value) is what the symlink points to - this doesn't affect the validation
201+
test_links = {
202+
"../../etc/other": b"/etc/other", # Relative path traversal
203+
"../../../etc/other": b"/etc/other", # Multiple levels up
204+
"/etc/other": b"/etc/other", # Absolute path
205+
"foo/../../../../../../etc/other": b"target", # Traversal with prefix
206+
}
207+
208+
@parameterized.expand(list(test_links.keys()))
209+
def test_extract_blocks_symlinks_outside_target_directory(self, invalid_path):
210+
"""Test that _extract blocks symlinks that would escape the target directory."""
211+
file_info = zipfile.ZipInfo(invalid_path)
212+
file_info.external_attr = (0o12 << 28) | (0o644 << 16) # Mark as symlink
213+
214+
# Create a mock zip_ref object with a read() method
215+
# In a real ZIP, read() returns the path the symlink points to, not file contents
216+
mock_zip_ref = Mock()
217+
mock_zip_ref.read.return_value = self.test_links[invalid_path]
218+
219+
with self.assertRaises(ValueError) as context:
220+
_extract(file_info, "/tmp/extract", mock_zip_ref)
221+
222+
self.assertIn("path traversal", str(context.exception).lower())

0 commit comments

Comments
 (0)