Original flaw
Earlier flawGit archive callers accepted output-affecting command options from untrusted input.
Sink: GitPython unsafe_git_archive_options for archive command construction
Originally written by Michael Trier
Loading…
How AI contributed
Incomplete remediationTarget: gitpython-developers/GitPython Tested: HEAD 07e80555 (2026-07-25), latest release 3.1.55, git version 2.50.1
Only the highlighted steps are this advisory. The first card is the earlier flaw the AI tried, and failed, to close.
Original flaw
Earlier flawSink: GitPython unsafe_git_archive_options for archive command construction
Originally written by Michael Trier
This advisoryGHSA-539M-9XH6-Q6RR
AI tried to fix this
The AI change was a real security patch, but it left the same advisory reachable.
Missed: Omitted --add-file and --add-virtual-file.
Fixed again
This is the patch that actually stops the same attack path.
AI-assisted fix: ChatGPT/Codex · GPT 5.6
Code comparison
--- a/git/repo/base.py+++ b/git/repo/base.py@@ -161,6 +161,20 @@ class Repo: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---configltkeygtltvaluegt """ + unsafe_git_archive_options = [+ # Allows arbitrary command execution through the remote git-upload-archive command.+ "--exec",+ # Writes output to a caller-controlled filesystem path.+ "--output",+ "-o",+ ]++ unsafe_git_revision_options = [+ # This option allows output to be written to arbitrary files before revision parsing.+ "--output",+ "-o",+ ]+ # Invariants config_level: ConfigLevels_Tup = ("system", "user", "global", "repository") """Represents the configuration level of a configuration file."""@@ -775,6 +789,7 @@ class Repo: self, rev: Union[str, Commit, "SymbolicReference", None] = None, paths: Union[PathLike, Sequence[PathLike]] = "",+ allow_unsafe_options: bool = False, **kwargs: Any, ) -> Iterator[Commit]: """An iterator of :class:`~git.objects.commit.Commit` objects representing the@@ -792,6 +807,9 @@ class Repo: Arguments to be passed to :manpage:`git-rev-list(1)`. Common ones are ``max_count`` and ``skip``. + :param allow_unsafe_options:+ Allow unsafe options in the revision argument, like ``--output``.+ :note: To receive only commits between two named revisions, use the ``"revA...revB"`` revision specifier.@@ -802,7 +820,18 @@ class Repo: if rev is None: rev = self.head.commit - return Commit.iter_items(self, rev, paths, **kwargs)+ if not allow_unsafe_options:+ Git.check_unsafe_options(+ options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options+ )++ return Commit.iter_items(+ self,+ rev,+ paths,+ allow_unsafe_options=allow_unsafe_options,+ **kwargs,+ ) def merge_base(self, *rev: TBD, **kwargs: Any) -> List[Commit]: R"""Find the closest common ancestor for the given revision@@ -1079,7 +1108,9 @@ class Repo: ) return active_branch - def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> Iterator["BlameEntry"]:+ def blame_incremental(+ self, rev: str | HEAD | None, file: str, allow_unsafe_options: bool = False, **kwargs: Any+ ) -> Iterator["BlameEntry"]: """Iterator for blame information for the given file at the given revision. Unlike :meth:`blame`, this does not return the actual file's contents, only a@@ -1090,6 +1121,9 @@ class Repo: uncommitted changes. Otherwise, anything successfully parsed by :manpage:`git-rev-parse(1)` is a valid option. + :param allow_unsafe_options:+ Allow unsafe options in revision argument, like ``--output``.+ :return: Lazy iterator of :class:`BlameEntry` tuples, where the commit indicates the commit to blame for the line, and range indicates a span of line numbers in@@ -1098,6 +1132,10 @@ class Repo: If you combine all line number ranges outputted by this command, you should get a continuous range spanning all line numbers in the file. """+ if not allow_unsafe_options:+ Git.check_unsafe_options(+ options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options+ ) data: bytes = self.git.blame(rev, "--", file, p=True, incremental=True, stdout_as_string=False, **kwargs) commits: Dict[bytes, Commit] = {}@@ -1176,7 +1214,8 @@ class Repo: rev: Union[str, HEAD, None], file: str, incremental: bool = False,- rev_opts: Optional[List[str]] = None,+ rev_opts: Optional[Sequence[str]] = None,+ allow_unsafe_options: bool = False, **kwargs: Any, ) -> List[List[Commit | List[str | bytes] | None]] | Iterator[BlameEntry] | None: """The blame information for the given file at the given revision.@@ -1186,6 +1225,9 @@ class Repo: uncommitted changes. Otherwise, anything successfully parsed by :manpage:`git-rev-parse(1)` is a valid option. + :param allow_unsafe_options:+ Allow unsafe options in revision argument, like ``--output``.+ :return: list: [git.Commit, list: [<line>]] @@ -1195,9 +1237,14 @@ class Repo: appearance. """ if incremental:- return self.blame_incremental(rev, file, **kwargs)- rev_opts = rev_opts or []- data: bytes = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)--- a/test/test_clone.py+++ b/test/test_clone.py@@ -117,11 +117,13 @@ class TestClone(TestBase): tmp_file = tmp_dir / "pwn" unsafe_options = [ f"--upload-pack='touch {tmp_file}'",+ f"--upl='touch {tmp_file}'", f"-u 'touch {tmp_file}'", f"-utouch {tmp_file}; false", f"-futouch${{IFS}}{tmp_file}; false", f"-qutouch${{IFS}}{tmp_file}; false", "--config=protocol.ext.allow=always",+ "--conf=protocol.ext.allow=always", "-c protocol.ext.allow=always", "-cprotocol.ext.allow=always", "-vcprotocol.ext.allow=always",@@ -134,8 +136,10 @@ class TestClone(TestBase): unsafe_options = [ {"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"},+ {"upl": f"touch {tmp_file}"}, {"u": f"touch {tmp_file}"}, {"config": "protocol.ext.allow=always"},+ {"conf": "protocol.ext.allow=always"}, {"c": "protocol.ext.allow=always"}, ] for unsafe_option in unsafe_options:--- a/test/test_repo.py+++ b/test/test_repo.py@@ -37,6 +37,8 @@ from git import ( Submodule, Tree, )+from git.exc import UnsafeOptionError+from git.exc import UnsafeProtocolError from git.exc import BadObject from git.repo.fun import touch from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree@@ -422,6 +424,54 @@ class TestRepo(TestBase): assert stream.tell() os.remove(stream.name) # Do it this way so we can inspect the file on failure. + def test_archive_rejects_unsafe_options(self):+ with tempfile.TemporaryDirectory() as tdir:+ output_marker = osp.join(tdir, "pwn")+ with self.assertRaises(UnsafeOptionError):+ self.rorepo.archive(io.BytesIO(), "0.1.6", exec=f"touch {output_marker}")+ assert not osp.exists(output_marker)+ with self.assertRaises(UnsafeOptionError):+ self.rorepo.archive(io.BytesIO(), "0.1.6", output=output_marker)+ assert not osp.exists(output_marker)++ def test_archive_rejects_unsafe_remote_protocol(self):+ with tempfile.TemporaryDirectory() as tdir:+ output_marker = osp.join(tdir, "pwn")+ with self.assertRaises(UnsafeProtocolError):+ self.rorepo.archive(io.BytesIO(), "HEAD", remote=f"ext::sh -c touch% {output_marker}")+ assert not osp.exists(output_marker)++ def test_archive_preserves_positional_allow_unsafe_options(self):+ with mock.patch.object(Git, "_call_process") as git:+ self.rorepo.archive(io.BytesIO(), "HEAD", None, True, exec="git-upload-archive")+ git.assert_called_once()++ def test_archive_accepts_stringifiable_remote(self):+ class StringifiableRemote:+ def __str__(self):+ return "origin"++ with mock.patch.object(Git, "_call_process") as git:+ self.rorepo.archive(io.BytesIO(), "HEAD", remote=StringifiableRemote())+ git.assert_called_once()++ def test_archive_rejects_unsafe_falsey_remote_protocol(self):+ class FalseyRemote:+ def __bool__(self):+ return False++ def __str__(self):+ return "ext::sh -c true"++ with self.assertRaises(UnsafeProtocolError):+ self.rorepo.archive(io.BytesIO(), "HEAD", remote=FalseyRemote())++ def test_iter_commits_rejects_unsafe_revision(self):+ with tempfile.TemporaryDirectory() as tdir:+ target = osp.join(tdir, "pwn")+ with self.assertRaises(UnsafeOptionError):+ list(self.rorepo.iter_commits(f"--output={target}", max_count=1))+ @mock.patch.object(Git, "_call_process") def test_should_display_blame_information(self, git): git.return_value = fixture("blame")@@ -471,6 +521,27 @@ class TestRepo(TestBase): assert c, "Should have executed at least one blame command" assert nml, "There should at least be one blame commit that contains multiple lines" + def test_blame_rejects_unsafe_revision(self):+ with tempfile.TemporaryDirectory() as tdir:+ output_marker = osp.join(tdir, "pwn")+ with self.assertRaises(UnsafeOptionError):+ self.rorepo.blame(f"--output={output_marker}", "README.md")+ assert not osp.exists(output_marker)++ def test_blame_rejects_unsafe_options(self):+ with tempfile.TemporaryDirectory() as tdir:+ output_marker = osp.join(tdir, "pwn")+ with self.assertRaises(UnsafeOptionError):+ self.rorepo.blame("HEAD", "README.md", output=output_marker)+ assert not osp.exists(output_marker)++ def test_blame_rejects_unsafe_rev_opts(self):+ with tempfile.TemporaryDirectory() as tdir:+ output_marker = osp.join(tdir, "pwn")+ with self.assertRaises(UnsafeOptionError):+ self.rorepo.blame("HEAD", "README.md", rev_opts=(f"--output={output_marker}",))+ assert not osp.exists(output_marker)+ @mock.patch.object(Git, "_call_process") def test_blame_incremental(self, git): # Loop over two fixtures, create a test fixture for 2.11.1+ syntax.--- 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 = [--- a/test/test_clone.py+++ b/test/test_clone.py@@ -132,6 +132,7 @@ class TestClone(TestBase): "-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 @@ class TestClone(TestBase): {"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):--- a/test/test_repo.py+++ b/test/test_repo.py@@ -433,6 +433,10 @@ class TestRepo(TestBase): 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:Candidate be1cbc3cdf48766f7b416dc4b5b3fa547a9c58d8287b570f9f9d3abf76b75cf7 · Fix 7d34a1b90568393c7af7fe8df799ccb1c264aa679743dc8c7e272d328601fbde
Releases
Advisory references