Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions gramps/gen/plug/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from ..const import GRAMPS_LOCALE as glocale
from ..const import USER_PLUGINS
from ..constfunc import mac
from ..utils.safearchive import is_safe_archive_member, is_safe_tar_member
from . import BasePluginManager
from ._pluginreg import make_environment

Expand Down Expand Up @@ -119,8 +120,14 @@ def __init__(self, buffer):
def extractall(self, path, members=None):
"""
Extract all of the files in the zip into path.

Raises ``ValueError`` if an entry's path would resolve outside of
``path`` (Zip Slip / CVE-2007-4559).
"""
names = self.zip_obj.namelist()
for name in names:
if not is_safe_archive_member(name, path):
raise ValueError("Unsafe path in archive member: %s" % name)
for name in self.get_paths(names):
fullname = os.path.join(path, name)
if not os.path.exists(fullname):
Expand Down Expand Up @@ -446,10 +453,16 @@ def load_addon_file(path, callback=None):
if len(good_gpr) > 0:
# Now, install the ok ones
try:
if isinstance(file_obj, tarfile.TarFile):
for member in file_obj.getmembers():
if not is_safe_tar_member(member, USER_PLUGINS):
raise tarfile.TarError(
"Unsafe path in archive member: %s" % member.name
)
file_obj.extractall(USER_PLUGINS)
except OSError:
except (OSError, ValueError, tarfile.TarError):
if callback:
callback(f"OSError installing '{path}', skipped!")
callback(f"Error installing '{path}', skipped!")
file_obj.close()
return False
if callback:
Expand Down
73 changes: 73 additions & 0 deletions gramps/gen/utils/safearchive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#
# Gramps - a GTK+/GNOME based genealogy program
#
# Copyright (C) 2026 Gramps Development Team
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, see <https://www.gnu.org/licenses/>.
#

"""
Helpers guarding archive extraction against path-traversal ("Zip Slip",
CVE-2007-4559): a crafted zip or tar file can use an absolute path, a
``../`` member name, or a symlink/hardlink to make a naive extractor
create or overwrite files outside the intended target directory.
"""

# -------------------------------------------------------------------------
#
# Standard Python modules
#
# -------------------------------------------------------------------------
import os
import tarfile


# -------------------------------------------------------------------------
#
# Functions
#
# -------------------------------------------------------------------------
def is_within_directory(directory: str, target: str) -> bool:
"""
Return True if ``target`` resolves to a path inside ``directory``.
"""
abs_directory = os.path.abspath(directory)
abs_target = os.path.abspath(target)
return os.path.commonpath([abs_directory, abs_target]) == abs_directory


def is_safe_archive_member(name: str, target_dir: str) -> bool:
"""
Check that extracting an archive member named ``name`` into
``target_dir`` cannot write outside of it.
"""
if os.path.isabs(name) or name.startswith(("/", "\\")):
return False
target_path = os.path.join(target_dir, name)
return is_within_directory(target_dir, target_path)


def is_safe_tar_member(tarinfo: tarfile.TarInfo, target_dir: str) -> bool:
"""
Check that extracting ``tarinfo`` into ``target_dir`` cannot write or
link outside of it.
"""
if not is_safe_archive_member(tarinfo.name, target_dir):
return False
if tarinfo.issym() or tarinfo.islnk():
target_path = os.path.join(target_dir, tarinfo.name)
link_target = os.path.join(os.path.dirname(target_path), tarinfo.linkname)
if not is_within_directory(target_dir, link_target):
return False
return True
120 changes: 120 additions & 0 deletions gramps/gen/utils/test/safearchive_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#
# Gramps - a GTK+/GNOME based genealogy program
#
# Copyright (C) 2026 Gramps Development Team
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, see <https://www.gnu.org/licenses/>.
#

"""
Unittest for the archive path-traversal guards ("Zip Slip", CVE-2007-4559).
"""

import os
import tarfile
import unittest

from gramps.gen.utils.safearchive import (
is_safe_archive_member,
is_safe_tar_member,
is_within_directory,
)


def _make_tarinfo(name: str, linkname: str = "", linktype: str | None = None):
tarinfo = tarfile.TarInfo(name=name)
if linktype == "sym":
tarinfo.type = tarfile.SYMTYPE
tarinfo.linkname = linkname
elif linktype == "link":
tarinfo.type = tarfile.LNKTYPE
tarinfo.linkname = linkname
return tarinfo


class IsWithinDirectoryCheck(unittest.TestCase):
def setUp(self):
self.target_dir = os.path.join(os.sep, "home", "user", "tree.media")

def test_subpath_is_within(self):
self.assertTrue(
is_within_directory(
self.target_dir, os.path.join(self.target_dir, "photo.jpg")
)
)

def test_sibling_path_is_not_within(self):
self.assertFalse(
is_within_directory(
self.target_dir,
os.path.join(os.path.dirname(self.target_dir), "other.media"),
)
)


class IsSafeArchiveMemberCheck(unittest.TestCase):
def setUp(self):
self.target_dir = os.path.join(os.sep, "home", "user", "tree.media")

def test_regular_relative_member_is_safe(self):
self.assertTrue(is_safe_archive_member("photo.jpg", self.target_dir))

def test_relative_subdir_member_is_safe(self):
name = os.path.join("sub", "photo.jpg")
self.assertTrue(is_safe_archive_member(name, self.target_dir))

def test_absolute_path_is_unsafe(self):
name = os.path.join(os.sep, "etc", "passwd")
self.assertFalse(is_safe_archive_member(name, self.target_dir))

def test_parent_traversal_is_unsafe(self):
name = os.path.join("..", "..", "..", "etc", "passwd")
self.assertFalse(is_safe_archive_member(name, self.target_dir))


class IsSafeTarMemberCheck(unittest.TestCase):
def setUp(self):
self.target_dir = os.path.join(os.sep, "home", "user", "tree.media")

def test_regular_relative_member_is_safe(self):
tarinfo = _make_tarinfo("photo.jpg")
self.assertTrue(is_safe_tar_member(tarinfo, self.target_dir))

def test_parent_traversal_is_unsafe(self):
tarinfo = _make_tarinfo(os.path.join("..", "..", "..", "etc", "passwd"))
self.assertFalse(is_safe_tar_member(tarinfo, self.target_dir))

def test_symlink_escaping_target_is_unsafe(self):
tarinfo = _make_tarinfo(
"innocuous",
linkname=os.path.join("..", "..", "etc", "passwd"),
linktype="sym",
)
self.assertFalse(is_safe_tar_member(tarinfo, self.target_dir))

def test_symlink_within_target_is_safe(self):
tarinfo = _make_tarinfo("link", linkname="photo.jpg", linktype="sym")
self.assertTrue(is_safe_tar_member(tarinfo, self.target_dir))

def test_hardlink_escaping_target_is_unsafe(self):
tarinfo = _make_tarinfo(
"innocuous",
linkname=os.path.join(os.sep, "etc", "passwd"),
linktype="link",
)
self.assertFalse(is_safe_tar_member(tarinfo, self.target_dir))


if __name__ == "__main__":
unittest.main()
5 changes: 5 additions & 0 deletions gramps/plugins/importer/importgpkg.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
# -------------------------------------------------------------------------
from gramps.gen.const import XMLFILE
from gramps.gen.utils.file import media_path
from gramps.gen.utils.safearchive import is_safe_tar_member

## we need absolute import as this is dynamically loaded:
from gramps.plugins.importer.importxml import importData
Expand Down Expand Up @@ -92,6 +93,10 @@ def impData(database, name, user):
try:
archive = tarfile.open(name)
for tarinfo in archive:
if not is_safe_tar_member(tarinfo, tmpdir_path):
raise tarfile.TarError(
"Unsafe path in archive member: %s" % tarinfo.name
)
archive.extract(tarinfo, tmpdir_path)
archive.close()
except:
Expand Down
57 changes: 57 additions & 0 deletions gramps/plugins/importer/test/importgpkg_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#
# Gramps - a GTK+/GNOME based genealogy program
#
# Copyright (C) 2026 Gramps Development Team
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, see <https://www.gnu.org/licenses/>.
#

"""
Unittest for the tarfile path-traversal guard in the Gramps package
importer (CVE-2007-4559). See also gramps.gen.utils.test.safearchive_test
for coverage of the underlying is_safe_tar_member() helper.
"""

import io
import os
import tarfile
import unittest

from gramps.gen.utils.safearchive import is_safe_tar_member


class MaliciousArchiveExtractionCheck(unittest.TestCase):
"""
Build an in-memory tar archive that mimics a malicious .gpkg file and
verify every unsafe member it contains is rejected before extraction,
the same way gramps.plugins.importer.importgpkg.impData() does.
"""

def test_all_members_of_malicious_archive_are_rejected(self):
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as archive:
evil = tarfile.TarInfo(name=os.path.join("..", "..", "evil.txt"))
data = b"payload"
evil.size = len(data)
archive.addfile(evil, io.BytesIO(data))
buffer.seek(0)

target_dir = os.path.join(os.sep, "home", "user", "tree.media")
with tarfile.open(fileobj=buffer) as archive:
for tarinfo in archive:
self.assertFalse(is_safe_tar_member(tarinfo, target_dir))


if __name__ == "__main__":
unittest.main()
3 changes: 3 additions & 0 deletions po/POTFILES.skip
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ gramps/gen/utils/location.py
gramps/gen/utils/lru.py
gramps/gen/utils/maclocale.py
gramps/gen/utils/resourcepath.py
gramps/gen/utils/safearchive.py
gramps/gen/utils/thumbnails.py
gramps/gen/utils/unittest.py
#
Expand All @@ -335,6 +336,7 @@ gramps/gen/utils/test/place_test.py
gramps/gen/utils/test/pypi_e2e_test.py
gramps/gen/utils/test/pypi_test.py
gramps/gen/utils/test/requirements_test.py
gramps/gen/utils/test/safearchive_test.py
#
# gui - GUI code
#
Expand Down Expand Up @@ -599,6 +601,7 @@ gramps/plugins/importer/__init__.py
#
gramps/plugins/importer/test/importgedcom_ambiguous_date_test.py
gramps/plugins/importer/test/importgeneweb_test.py
gramps/plugins/importer/test/importgpkg_test.py
gramps/plugins/importer/test/importvcard_test.py
#
# plugins/lib directory
Expand Down
Loading