From 3af0c2516c5e18c829da30338614688f6b69b49c Mon Sep 17 00:00:00 2001 From: "GPT 5.6" Date: Sun, 26 Jul 2026 05:54:27 +0200 Subject: [PATCH 1/3] Block unsafe checkout-index and tag file options GHSA-3f7w-8rr8-f37f reports that caller-controlled options forwarded by IndexFile.checkout() and TagReference.create() can make Git write to or read from arbitrary filesystem paths. Add regression coverage proving the unsafe options are rejected by default while preserving the explicit allow_unsafe_options escape hatch. Reuse GitPython's existing option candidate normalization and unsafe-option guard at both public API boundaries. Git baseline: a23bace963d508bd96983cc637131392d3face18. Documentation/git-checkout-index.adoc defines --prefix as prepending an output path, and Documentation/git-tag.adoc defines -F/--file as reading a tag message from a file. Co-authored-by: Sebastian Thiel --- git/index/base.py | 12 ++++++++++++ git/refs/tag.py | 13 +++++++++++++ test/test_index.py | 10 ++++++++++ test/test_refs.py | 13 +++++++++++++ 4 files changed, 48 insertions(+) diff --git a/git/index/base.py b/git/index/base.py index 8d7dbfc1f..4dd1fcb1d 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -130,6 +130,8 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): index directly before operating on it using the git command. """ + unsafe_git_checkout_index_options = ["--prefix"] + __slots__ = ("repo", "version", "entries", "_extension_data", "_file_path") _VERSION = 2 @@ -1212,6 +1214,7 @@ def checkout( paths: Union[None, Iterable[PathLike]] = None, force: bool = False, fprogress: Callable = lambda *args: None, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> Union[None, Iterator[PathLike], Sequence[PathLike]]: """Check out the given paths or all files from the version known to the index @@ -1238,6 +1241,9 @@ def checkout( no explicit paths are given. Otherwise progress information will be send prior and after a file has been checked out. + :param allow_unsafe_options: + Allow unsafe options, such as ``--prefix``. + :param kwargs: Additional arguments to be passed to :manpage:`git-checkout-index(1)`. @@ -1261,6 +1267,12 @@ def checkout( i.e. if you want :manpage:`git-checkout(1)`-like behaviour, use ``head.checkout`` instead of ``index.checkout``. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=self.unsafe_git_checkout_index_options, + ) + args = ["--index"] if force: args.append("--force") diff --git a/git/refs/tag.py b/git/refs/tag.py index 4525b09cb..055722e3b 100644 --- a/git/refs/tag.py +++ b/git/refs/tag.py @@ -16,6 +16,7 @@ from typing import Any, TYPE_CHECKING, Type, Union +from git.cmd import Git from git.types import AnyGitObject, PathLike if TYPE_CHECKING: @@ -42,6 +43,8 @@ class TagReference(Reference): __slots__ = () + unsafe_git_tag_options = ["--file", "-F"] + _common_default = "tags" _common_path_default = Reference._common_path_default + "/" + _common_default @@ -92,6 +95,7 @@ def create( reference: Union[str, "SymbolicReference"] = "HEAD", logmsg: Union[str, None] = None, force: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "TagReference": """Create a new tag reference. @@ -121,12 +125,21 @@ def create( :param force: If ``True``, force creation of a tag even though that tag already exists. + :param allow_unsafe_options: + Allow unsafe options, such as ``--file``. + :param kwargs: Additional keyword arguments to be passed to :manpage:`git-tag(1)`. :return: A new :class:`TagReference`. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=cls.unsafe_git_tag_options, + ) + if "ref" in kwargs and kwargs["ref"]: reference = kwargs["ref"] diff --git a/test/test_index.py b/test/test_index.py index 881dec19f..3ad5a457f 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -31,6 +31,7 @@ HookExecutionError, InvalidGitRepositoryError, UnmergedEntriesError, + UnsafeOptionError, ) from git.index.fun import hook_path, run_commit_hook from git.index.typ import BaseIndexEntry, IndexEntry @@ -202,6 +203,15 @@ def _make_hook(git_dir, name, content, make_exec=True): @ddt.ddt class TestIndex(TestBase): + @with_rw_repo("HEAD") + def test_checkout_rejects_unsafe_prefix(self, rw_repo): + with tempfile.TemporaryDirectory() as target: + with self.assertRaises(UnsafeOptionError): + rw_repo.index.checkout(prefix=f"{target}/") + + rw_repo.index.checkout(prefix=f"{target}/", allow_unsafe_options=True) + self.assertTrue(osp.isfile(osp.join(target, "CHANGES"))) + def __init__(self, *args): super().__init__(*args) self._reset_progress() diff --git a/test/test_refs.py b/test/test_refs.py index d77b34eba..6481b54a8 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -23,6 +23,7 @@ SymbolicReference, TagReference, ) +from git.exc import UnsafeOptionError from git.objects.tag import TagObject import git.refs as refs from git.util import Actor @@ -60,6 +61,18 @@ def test_from_path(self): # Check remoteness assert Reference(self.rorepo, "refs/remotes/origin").is_remote() + @with_rw_repo("HEAD") + def test_tag_create_rejects_unsafe_file_options(self, rw_repo): + with tempfile.NamedTemporaryFile("w", encoding="utf-8") as message: + message.write("private tag message") + message.flush() + for index, option in enumerate(({"F": message.name}, {"file": message.name})): + with self.assertRaises(UnsafeOptionError): + TagReference.create(rw_repo, f"unsafe-{index}", **option) + + tag = TagReference.create(rw_repo, "allowed-file", F=message.name, allow_unsafe_options=True) + self.assertEqual(tag.tag.message, "private tag message") + def test_from_pathlike(self): # Should be able to create any reference directly. for ref_type in (Reference, Head, TagReference, RemoteReference): From 7a4f5dcb7bf3cbcbf6e438017efcdfe0bc0d36ca Mon Sep 17 00:00:00 2001 From: "GPT 5.6" Date: Sun, 26 Jul 2026 06:00:59 +0200 Subject: [PATCH 2/3] Block unsafe archive additions and bundle URI GHSA-539m-9xh6-q6rr reports incomplete unsafe option lists for archive inputs and clone bundle URLs. Caller-controlled archive additions can expose or inject filesystem content, while clone --bundle-uri can dereference an additional caller-controlled URL. Extend the existing archive denylist with --add-file and --add-virtual-file, and the clone denylist with --bundle-uri. Regression coverage exercises archive kwargs plus both clone multi-option and keyword paths. Git baseline: a23bace963d508bd96983cc637131392d3face18. Documentation/git-archive.adoc defines --add-file/--add-virtual-file, and Documentation/git-clone.adoc defines --bundle-uri as fetching from the supplied URI. Co-authored-by: Sebastian Thiel --- git/repo/base.py | 6 ++++++ test/test_clone.py | 2 ++ test/test_repo.py | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/git/repo/base.py b/git/repo/base.py index ae99ed5f9..93d17252c 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -151,6 +151,8 @@ class Repo: "-c", # Can install hooks that execute during clone: "--template", + # Fetches from a caller-controlled URL: + "--bundle-uri", ] """Options to :manpage:`git-clone(1)` that allow arbitrary commands to be executed. @@ -172,6 +174,10 @@ class Repo: # Writes output to a caller-controlled filesystem path. "--output", "-o", + # Reads from a caller-controlled filesystem path: + "--add-file", + # Injects a caller-controlled path and contents: + "--add-virtual-file", ] unsafe_git_revision_options = [ diff --git a/test/test_clone.py b/test/test_clone.py index 0c8155944..5af67613a 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -132,6 +132,7 @@ def test_clone_unsafe_options(self, rw_repo): "-cprotocol.ext.allow=always", "-vcprotocol.ext.allow=always", f"--template={tmp_dir}", + f"--bundle-uri=file://{tmp_dir}", ] for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): @@ -147,6 +148,7 @@ def test_clone_unsafe_options(self, rw_repo): {"conf": "protocol.ext.allow=always"}, {"c": "protocol.ext.allow=always"}, {"template": tmp_dir}, + {"bundle_uri": f"file://{tmp_dir}"}, ] for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): diff --git a/test/test_repo.py b/test/test_repo.py index 27246db88..7c7f1dd34 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -433,6 +433,10 @@ def test_archive_rejects_unsafe_options(self): with self.assertRaises(UnsafeOptionError): self.rorepo.archive(io.BytesIO(), "0.1.6", output=output_marker) assert not osp.exists(output_marker) + with self.assertRaises(UnsafeOptionError): + self.rorepo.archive(io.BytesIO(), "0.1.6", add_file=output_marker) + with self.assertRaises(UnsafeOptionError): + self.rorepo.archive(io.BytesIO(), "0.1.6", add_virtual_file="file:content") def test_archive_rejects_unsafe_remote_protocol(self): with tempfile.TemporaryDirectory() as tdir: From 52199b39ce02c6e8f9fb49e95628b59deebf44c0 Mon Sep 17 00:00:00 2001 From: Byron Date: Sun, 26 Jul 2026 08:56:06 +0200 Subject: [PATCH 3/3] address review comments --- git/repo/base.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/git/repo/base.py b/git/repo/base.py index 93d17252c..6594101f3 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -151,10 +151,10 @@ class Repo: "-c", # Can install hooks that execute during clone: "--template", - # Fetches from a caller-controlled URL: + # Fetches from an additional caller-controlled URI: "--bundle-uri", ] - """Options to :manpage:`git-clone(1)` that allow arbitrary commands to be executed. + """Options to :manpage:`git-clone(1)` that permit unsafe command execution or I/O. The ``--upload-pack``/``-u`` option allows users to execute arbitrary commands directly: @@ -166,6 +166,11 @@ class Repo: The ``--template`` option can install hooks that execute during clone: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---templatetemplate-directory + + The ``--bundle-uri`` option fetches from an additional URI before fetching from the + clone URL. An untrusted value can therefore make Git access local files or + unintended network resources: + https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---bundle-uriuri """ unsafe_git_archive_options = [