Skip to content

Commit 72145af

Browse files
Merge pull request #586 from nikita-khapre/fix-path-traversal-zip-extraction
Fix path traversal in zip extraction
2 parents 20629ea + d4c16f5 commit 72145af

6 files changed

Lines changed: 89 additions & 26 deletions

File tree

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
Changelog
22
=========
3+
--------------------
4+
3.27.3 (2026-06-29)
5+
--------------------
6+
- Fix path traversal vulnerability in zip extraction for ``eb labs download`` and ``eb create``
7+
38
--------------------
49
3.27.2 (2026-05-13)
510
--------------------

ebcli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@
1111
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1212
# ANY KIND, either express or implied. See the License for the specific
1313
# language governing permissions and limitations under the License.
14-
__version__ = '3.27.2'
14+
__version__ = '3.27.3'

ebcli/core/fileoperations.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
from ebcli.objects.exceptions import (
4141
NotInitializedError,
4242
InvalidSyntaxError,
43-
NotFoundError
43+
NotFoundError,
44+
ValidationError
4445
)
4546
from ebcli.core.ebglobals import Constants
4647

@@ -482,14 +483,19 @@ def unzip_folder(file_location, directory):
482483
if not os.path.isdir(directory):
483484
os.makedirs(directory)
484485

485-
zip = zipfile.ZipFile(file_location, 'r', allowZip64=True)
486-
for cur_file in zip.namelist():
487-
if not cur_file.endswith('/'):
488-
root, name = os.path.split(cur_file)
489-
path = os.path.normpath(os.path.join(directory, root))
490-
if not os.path.isdir(path):
491-
os.makedirs(path)
492-
open(os.path.join(path, name), 'wb').write(zip.read(cur_file))
486+
real_directory = os.path.realpath(directory)
487+
with zipfile.ZipFile(file_location, 'r', allowZip64=True) as zf:
488+
for cur_file in zf.namelist():
489+
if not cur_file.endswith('/'):
490+
root, name = os.path.split(cur_file)
491+
path = os.path.normpath(os.path.join(directory, root))
492+
target_file = os.path.realpath(os.path.join(path, name))
493+
if not target_file.startswith(real_directory + os.sep):
494+
raise ValidationError(
495+
'Zip entry "{}" would extract outside the target directory'.format(cur_file))
496+
if not os.path.isdir(path):
497+
os.makedirs(path)
498+
open(os.path.join(path, name), 'wb').write(zf.read(cur_file))
493499

494500

495501
def delete_app_file(app_name):

ebcli/operations/createops.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
# language governing permissions and limitations under the License.
1313
import os
1414
import re
15-
from zipfile import ZipFile
1615

1716
from cement.utils.misc import minimal_logger
1817

@@ -164,7 +163,7 @@ def download_and_extract_sample_app(env_name):
164163
zip_file_location = '.elasticbeanstalk/.sample_app_download.zip'
165164
io.echo('INFO: {}'.format(strings['create.downloading_sample_application']))
166165
download_application_version(url, zip_file_location)
167-
ZipFile(zip_file_location, 'r', allowZip64=True).extractall()
166+
fileoperations.unzip_folder(zip_file_location, os.getcwd())
168167
os.remove(zip_file_location)
169168
io.echo('INFO: {}'.format(strings['create.sample_application_download_complete']))
170169
except NotAuthorizedError as e:

tests/unit/core/test_fileoperations.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
from ebcli.core import fileoperations
2727
from ebcli.objects.buildconfiguration import BuildConfiguration
28-
from ebcli.objects.exceptions import NotInitializedError, NotFoundError
28+
from ebcli.objects.exceptions import NotInitializedError, NotFoundError, ValidationError
2929

3030

3131
class TestFileOperations(unittest.TestCase):
@@ -1060,3 +1060,63 @@ def test___validate_file_for_archive__ignores_socket_files(
10601060
actual = fileoperations._validate_file_for_archive(filepath)
10611061

10621062
self.assertFalse(actual)
1063+
1064+
1065+
class TestUnzipFolderPathTraversal(unittest.TestCase):
1066+
def setUp(self):
1067+
self.test_dir = os.path.join(os.path.dirname(__file__), '_test_unzip_traversal')
1068+
self.extract_dir = os.path.join(self.test_dir, 'extract')
1069+
os.makedirs(self.extract_dir, exist_ok=True)
1070+
1071+
def tearDown(self):
1072+
shutil.rmtree(self.test_dir, ignore_errors=True)
1073+
1074+
def _make_zip(self, entries, zip_path=None):
1075+
if zip_path is None:
1076+
zip_path = os.path.join(self.test_dir, 'test.zip')
1077+
with zipfile.ZipFile(zip_path, 'w') as zf:
1078+
for name, content in entries.items():
1079+
zf.writestr(name, content)
1080+
return zip_path
1081+
1082+
def test_unzip_folder__normal_entries_extract_correctly(self):
1083+
zip_path = self._make_zip({
1084+
'app.py': 'print("hello")',
1085+
'subdir/config.yml': 'key: value',
1086+
'a/b/c/deep.txt': 'deep content',
1087+
'./foo/bar.txt': 'content',
1088+
})
1089+
fileoperations.unzip_folder(zip_path, self.extract_dir)
1090+
1091+
self.assertTrue(os.path.exists(os.path.join(self.extract_dir, 'app.py')))
1092+
self.assertTrue(os.path.exists(os.path.join(self.extract_dir, 'subdir', 'config.yml')))
1093+
self.assertTrue(os.path.exists(os.path.join(self.extract_dir, 'a', 'b', 'c', 'deep.txt')))
1094+
self.assertTrue(os.path.exists(os.path.join(self.extract_dir, 'foo', 'bar.txt')))
1095+
1096+
def test_unzip_folder__path_traversal_raises_error(self):
1097+
zip_path = self._make_zip({
1098+
'../../../tmp/pwned.txt': 'PWNED',
1099+
})
1100+
with self.assertRaises(ValidationError):
1101+
fileoperations.unzip_folder(zip_path, self.extract_dir)
1102+
1103+
self.assertFalse(os.path.exists(os.path.join(self.test_dir, 'pwned.txt')))
1104+
1105+
def test_unzip_folder__absolute_path_entry_raises_error(self):
1106+
zip_path = self._make_zip({
1107+
'/tmp/absolute_pwned.txt': 'PWNED',
1108+
})
1109+
with self.assertRaises(ValidationError):
1110+
fileoperations.unzip_folder(zip_path, self.extract_dir)
1111+
1112+
def test_unzip_folder__legitimate_deep_and_redundant_paths(self):
1113+
zip_path = self._make_zip({
1114+
'a/b/c/d/e/deep.txt': 'deep content',
1115+
'./foo/bar.txt': 'content1',
1116+
'foo//baz.txt': 'content2',
1117+
})
1118+
fileoperations.unzip_folder(zip_path, self.extract_dir)
1119+
1120+
self.assertTrue(os.path.exists(os.path.join(self.extract_dir, 'a', 'b', 'c', 'd', 'e', 'deep.txt')))
1121+
self.assertTrue(os.path.exists(os.path.join(self.extract_dir, 'foo', 'bar.txt')))
1122+
self.assertTrue(os.path.exists(os.path.join(self.extract_dir, 'foo', 'baz.txt')))

tests/unit/operations/test_createops.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -425,19 +425,17 @@ def test_download_application_version(
425425

426426
@mock.patch('ebcli.operations.createops.retrieve_application_version_url')
427427
@mock.patch('ebcli.operations.createops.download_application_version')
428-
@mock.patch('ebcli.operations.createops.ZipFile')
428+
@mock.patch('ebcli.operations.createops.fileoperations.unzip_folder')
429429
@mock.patch('ebcli.operations.createops.os.remove')
430430
@mock.patch('ebcli.operations.createops.io.echo')
431431
def test_download_and_extract_sample_app__successfully_downloads_and_extracts(
432432
self,
433433
echo_mock,
434434
remove_mock,
435-
ZipFile_mock,
435+
unzip_folder_mock,
436436
download_application_version_mock,
437437
retrieve_application_version_url_mock
438438
):
439-
zipfile_mock = mock.MagicMock()
440-
ZipFile_mock.return_value = zipfile_mock
441439
retrieve_application_version_url_mock.return_value = 'http://app-server.com'
442440

443441
createops.download_and_extract_sample_app('my-environment')
@@ -447,12 +445,7 @@ def test_download_and_extract_sample_app__successfully_downloads_and_extracts(
447445
'http://app-server.com',
448446
'.elasticbeanstalk/.sample_app_download.zip'
449447
)
450-
ZipFile_mock.assert_called_once_with(
451-
'.elasticbeanstalk/.sample_app_download.zip',
452-
'r',
453-
allowZip64=True
454-
)
455-
zipfile_mock.extractallassert_called_once_with()
448+
unzip_folder_mock.assert_called_once()
456449
remove_mock.assert_called_once_with('.elasticbeanstalk/.sample_app_download.zip')
457450
echo_mock.assert_has_calls(
458451
[
@@ -463,7 +456,7 @@ def test_download_and_extract_sample_app__successfully_downloads_and_extracts(
463456

464457
@mock.patch('ebcli.operations.createops.retrieve_application_version_url')
465458
@mock.patch('ebcli.operations.createops.download_application_version')
466-
@mock.patch('ebcli.operations.createops.ZipFile')
459+
@mock.patch('ebcli.operations.createops.fileoperations.unzip_folder')
467460
@mock.patch('ebcli.operations.createops.os.remove')
468461
@mock.patch('ebcli.operations.createops.io.echo')
469462
@mock.patch('ebcli.operations.createops.io.log_warning')
@@ -472,7 +465,7 @@ def test_download_and_extract_sample_app__lacks_permissions_to_query_cloudformat
472465
lgo_warning_mock,
473466
echo_mock,
474467
remove_mock,
475-
ZipFile_mock,
468+
unzip_folder_mock,
476469
download_application_version_mock,
477470
retrieve_application_version_url_mock
478471
):
@@ -487,7 +480,7 @@ def test_download_and_extract_sample_app__lacks_permissions_to_query_cloudformat
487480
'http://app-server.com',
488481
'.elasticbeanstalk/.sample_app_download.zip'
489482
)
490-
ZipFile_mock.assert_not_called()
483+
unzip_folder_mock.assert_not_called()
491484
remove_mock.assert_not_called()
492485
echo_mock.assert_called_once_with(
493486
'INFO: Downloading sample application to the current directory.'

0 commit comments

Comments
 (0)