Skip to content

Commit 065ca79

Browse files
committed
cifuzz: reject unsafe tar members in GitHub Actions artifacts
1 parent 1d0f105 commit 065ca79

File tree

2 files changed

+69
-3
lines changed

2 files changed

+69
-3
lines changed

infra/cifuzz/filestore/github_actions/__init__.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""Implementation of a filestore using Github actions artifacts."""
1515
import logging
1616
import os
17+
from pathlib import PurePosixPath
1718
import shutil
1819
import sys
1920
import tarfile
@@ -127,10 +128,12 @@ def _download_artifact(self, name, dst_directory):
127128
logging.error('Artifact zip did not contain a tarfile.')
128129
return False
129130

130-
# TODO(jonathanmetzman): Replace this with archive.unpack from
131-
# libClusterFuzz so we can avoid path traversal issues.
132131
with tarfile.TarFile(artifact_tarfile_path) as artifact_tarfile:
133-
artifact_tarfile.extractall(dst_directory)
132+
try:
133+
_safe_extract_tar(artifact_tarfile, dst_directory)
134+
except tarfile.TarError as error:
135+
logging.error('Unsafe artifact tarfile: %s', error)
136+
return False
134137
return True
135138

136139
def _raw_download_artifact(self, name, dst_directory):
@@ -159,6 +162,31 @@ def download_coverage(self, name, dst_directory):
159162
return self._download_artifact(self.COVERAGE_PREFIX + name, dst_directory)
160163

161164

165+
def _is_safe_tar_member(member):
166+
"""Returns whether |member| is safe to extract."""
167+
member_path = PurePosixPath(member.name)
168+
169+
if member_path.is_absolute():
170+
return False
171+
172+
if '..' in member_path.parts:
173+
return False
174+
175+
if member.issym() or member.islnk():
176+
return False
177+
178+
return True
179+
180+
181+
def _safe_extract_tar(artifact_tarfile, dst_directory):
182+
"""Safely extracts |artifact_tarfile| into |dst_directory|."""
183+
for member in artifact_tarfile.getmembers():
184+
if not _is_safe_tar_member(member):
185+
raise tarfile.TarError(f'Unsafe tar member: {member.name}')
186+
187+
artifact_tarfile.extractall(dst_directory)
188+
189+
162190
def _upload_artifact_with_upload_js(name, artifact_paths, directory):
163191
"""Uploads the artifacts in |artifact_paths| that are located in |directory|
164192
to |name|, using the upload.js script."""

infra/cifuzz/filestore/github_actions/github_actions_test.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,44 @@ def mock_download_and_unpack_zip_impl(url, download_artifact_temp_dir,
226226
os.path.join(artifact_download_dst_dir,
227227
os.path.basename(self.testcase))))
228228

229+
@mock.patch('filestore.github_actions.GithubActionsFilestore._find_artifact')
230+
@mock.patch('http_utils.download_and_unpack_zip')
231+
def test_download_build_rejects_traversal_tar_member(
232+
self, mock_download_and_unpack_zip, mock_find_artifact):
233+
"""Tests that traversal tar entries are rejected."""
234+
artifact_name = 'cifuzz-build-address-commit'
235+
236+
def fake_download_and_unpack_zip(download_url, dst_directory, headers=None):
237+
del download_url, headers
238+
tar_path = os.path.join(dst_directory, artifact_name + '.tar')
239+
safe_file = os.path.join(dst_directory, 'demo_fuzzer')
240+
traversal_file = os.path.join(dst_directory, 'escape.txt')
241+
242+
with open(safe_file, 'w', encoding='utf-8') as file_handle:
243+
file_handle.write('demo')
244+
245+
with open(traversal_file, 'w', encoding='utf-8') as file_handle:
246+
file_handle.write('escape')
247+
248+
with tarfile.open(tar_path, 'w') as tar_handle:
249+
tar_handle.add(safe_file, arcname='demo_fuzzer')
250+
tar_handle.add(traversal_file, arcname='../escape.txt')
251+
return True
252+
253+
mock_download_and_unpack_zip.side_effect = fake_download_and_unpack_zip
254+
mock_find_artifact.return_value = {
255+
'expired': False,
256+
'name': artifact_name,
257+
'archive_download_url': 'http://example.com/download'
258+
}
259+
260+
filestore = github_actions.GithubActionsFilestore(self.config)
261+
result = filestore.download_build('address-commit', self.local_dir)
262+
263+
self.assertFalse(result)
264+
self.assertFalse(
265+
os.path.exists(os.path.join(self.local_dir, 'demo_fuzzer')))
266+
229267
@mock.patch('filestore.github_actions.github_api.list_artifacts')
230268
def test_find_artifact(self, mock_list_artifacts):
231269
"""Tests that _find_artifact works as intended."""

0 commit comments

Comments
 (0)