77from tempfile import NamedTemporaryFile , mkdtemp
88from unittest import TestCase
99from unittest import skipIf
10- from unittest .mock import patch
10+ from unittest .mock import patch , Mock
1111from 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.
1616SKIP_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
157176class 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