diff --git a/scripts/ziptools.py b/scripts/ziptools.py index bdb3155e..0deb8c6f 100644 --- a/scripts/ziptools.py +++ b/scripts/ziptools.py @@ -107,6 +107,9 @@ def safe_extract_zip( """ dest = os.path.realpath(dest_dir) os.makedirs(dest, exist_ok=True) + # Reading the umask sets it; it is put back at once. + umask = os.umask(0) + os.umask(umask) with zipfile.ZipFile(zip_path, "r") as zf: members = zf.infolist() if len(members) > max_members: @@ -186,6 +189,9 @@ def safe_extract_zip( f"ZIP member size mismatch for {name}: " f"{actual_size} != {member.file_size}" ) + # The member's own mode, else what a plain open() gives; the + # temporary file is private and would keep 0600 otherwise. + os.chmod(tmp_path, ((mode & 0o777) or 0o666) & ~umask) os.replace(tmp_path, target) tmp_path = "" finally: diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 84264c08..7c4705f2 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -986,6 +986,30 @@ class ArchiveSecurityRegressions(unittest.TestCase): with self.assertRaisesRegex(ValueError, "members"): safe_extract_zip(str(archive), str(root / "out"), max_members=1) + def test_member_modes_follow_the_archive_and_the_umask(self): + """Every member came out 0600, the mode of its temporary file: the + pico8 and vox launchers the RetroDECK pack keeps 0755 lost their + execute bit once download.py extracted them.""" + with tempfile.TemporaryDirectory(dir=TMP_ROOT) as directory: + root = Path(directory) + archive = self._zip_path(root) + program = zipfile.ZipInfo("pico8") + program.create_system = 3 + program.external_attr = (stat.S_IFREG | 0o755) << 16 + plain = zipfile.ZipInfo("bios.bin") + plain.create_system = 0 + plain.external_attr = 0x20 # MS-DOS archive bit, no Unix mode + with zipfile.ZipFile(archive, "w") as handle: + handle.writestr(program, b"\x7fELF") + handle.writestr(plain, b"data") + umask = os.umask(0o022) + try: + safe_extract_zip(str(archive), str(root / "out")) + finally: + os.umask(umask) + self.assertEqual((root / "out" / "pico8").stat().st_mode & 0o777, 0o755) + self.assertEqual((root / "out" / "bios.bin").stat().st_mode & 0o777, 0o644) + def test_symlink_member_is_rejected(self): with tempfile.TemporaryDirectory(dir=TMP_ROOT) as directory: root = Path(directory)