Original flaw
Earlier flawGit blame accepted unsafe file-related revision options.
Sink: GitPython git/repo/base.py Repo.blame option handling
Originally written by Michael Trier
Loading…
How AI contributed
Incomplete remediationOnly 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 git/repo/base.py Repo.blame option handling
Originally written by Michael Trier
This advisoryGHSA-5XXX-QHH7-9287
AI tried to fix this
The AI change was a real security patch, but it left the same advisory reachable.
Missed: Omitted --contents and -S.
Fixed again
This is the patch that actually stops the same attack path.
Code comparison
--- a/git/cmd.py+++ b/git/cmd.py@@ -649,6 +649,11 @@ class Git(metaclass=_GitMeta): re_unsafe_protocol = re.compile(r"(.+)::.+") + unsafe_git_ls_remote_options = [+ # This option allows arbitrary command execution in git-ls-remote.+ "--upload-pack",+ ]+ def __getstate__(self) -> Dict[str, Any]: return slots_to_dict(self, exclude=self._excluded_) @@ -1022,6 +1027,20 @@ class Git(metaclass=_GitMeta): f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." ) + @classmethod+ def _option_candidates(cls, args: Sequence[Any] =, kwargs: Optional[Mapping[str, Any]] = None) -> List[str]:+ """Collect possible option spellings before command-line transformation."""+ options = [+ option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-")+ ]+ if kwargs:+ for key, value in kwargs.items():+ values = value if isinstance(value, (list, tuple)) else (value,)+ if any(value is True or (value is not False and value is not None) for value in values):+ key = str(key)+ options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}")+ return options+ AutoInterrupt: TypeAlias = _AutoInterrupt CatFileContentStream: TypeAlias = _CatFileContentStream@@ -1079,6 +1098,22 @@ class Git(metaclass=_GitMeta): self._persistent_git_options = self.transform_kwargs(split_single_char_options=True, **kwargs) + def ls_remote(+ self,+ *args: Any,+ allow_unsafe_options: bool = False,+ **kwargs: Any,+ ) -> Union[str, bytes, Tuple[int, Union[str, bytes], str], "Git.AutoInterrupt"]:+ """List references in a remote repository.++ :param allow_unsafe_options:+ Allow unsafe options, like ``--upload-pack``.+ """+ if not allow_unsafe_options:+ candidate_options = self._option_candidates(args, kwargs)+ Git.check_unsafe_options(options=candidate_options, unsafe_options=self.unsafe_git_ls_remote_options)+ return self._call_process("ls_remote", *args, **kwargs)+ @property def working_dir(self) -> Union[None, PathLike]: """:return: Git directory we are working on"""@@ -1585,7 +1620,7 @@ class Git(metaclass=_GitMeta): return args @classmethod- def _unpack_args(cls, arg_list: Sequence[str]) -> List[str]:+ def _unpack_args(cls, arg_list: Sequence[Any]) -> List[str]: outlist = [] if isinstance(arg_list, (list, tuple)): for arg in arg_list:--- 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_remote.py+++ b/test/test_remote.py@@ -9,7 +9,7 @@ from pathlib import Path import random import sys import tempfile-from unittest import skipIf+from unittest import mock, skipIf import pytest @@ -1017,6 +1017,47 @@ class TestRemote(TestBase): assert tmp_file.exists() tmp_file.unlink() + @with_rw_repo("HEAD")+ def test_ls_remote_unsafe_options(self, rw_repo):+ with tempfile.TemporaryDirectory() as tdir:+ tmp_dir = Path(tdir)+ tmp_file = tmp_dir / "pwn"+ unsafe_options = [+ {"upload-pack": f"touch {tmp_file}"},+ {"upload_pack": f"touch {tmp_file}"},+ {"upl": f"touch {tmp_file}"},+ ]+ for unsafe_option in unsafe_options:+ with self.assertRaises(UnsafeOptionError):+ rw_repo.git.ls_remote(".", **unsafe_option)+ with self.assertRaises(UnsafeOptionError):+ rw_repo.git.ls_remote([f"--upload-pack={tmp_file}"], ".")+ with self.assertRaises(UnsafeOptionError):+ rw_repo.git.ls_remote([f"--upl={tmp_file}"], ".")+ with self.assertRaises(UnsafeOptionError):+ rw_repo.git.ls_remote(f"--upload-pack={tmp_file}", ".")+ with self.assertRaises(UnsafeOptionError):+ rw_repo.git.ls_remote(f"--upl={tmp_file}", ".")+ with self.assertRaises(UnsafeOptionError):+ rw_repo.git.ls_remote("--upload-pack", "touch", ".")+ with self.assertRaises(UnsafeOptionError):+ rw_repo.git.ls_remote("--refs", ".", upl=f"touch {tmp_file}")++ def test_ls_remote_allows_operand_named_like_unsafe_option(self):+ with mock.patch.object(Git, "_call_process") as git:+ Git().ls_remote("upload-pack")+ git.assert_called_once()++ @with_rw_repo("HEAD")+ def test_ls_remote_unsafe_options_allowed(self, rw_repo):+ with tempfile.TemporaryDirectory() as tdir:+ tmp_dir = Path(tdir)+ tmp_file = tmp_dir / "pwn"+ unsafe_options = [{"upload-pack": f"touch {tmp_file}"}]+ for unsafe_option in unsafe_options:+ with self.assertRaises(GitCommandError):+ rw_repo.git.ls_remote(".", **unsafe_option, allow_unsafe_options=True)+ @with_rw_and_rw_remote_repo("0.1.6") def test_fetch_unsafe_branch_name(self, rw_repo, remote_repo): # Create branch with a name containing a NBSP--- a/git/cmd.py+++ b/git/cmd.py@@ -652,6 +652,7 @@ class Git(metaclass=_GitMeta): unsafe_git_ls_remote_options = [ # This option allows arbitrary command execution in git-ls-remote. "--upload-pack",+ "--exec", ] unsafe_git_pathspec_from_file_options = [@@ -976,7 +977,9 @@ class Git(metaclass=_GitMeta): return dashify(option_tokens[0]) @classmethod- def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None:+ def check_unsafe_options(+ cls, options: List[str], unsafe_options: List[str], clusterable_short_options: str = "46flnqsv"+ ) -> None: """Raise :class:`~git.exc.UnsafeOptionError` for blocked option spellings. In addition to exact matches, this rejects abbreviated long options accepted@@ -1011,7 +1014,7 @@ class Git(metaclass=_GitMeta): # These value-less Git flags can be clustered before another short option # (for example, ``-fuVALUE``). Stop at any other character because it may # begin an attached value, as ``o`` does in the safe option ``-oupstream``.- clusterable_short_options = frozenset("46flnqsv")+ clusterable_short_options_set = frozenset(clusterable_short_options) options_are_kwargs = all(not option.startswith("-") for option in options) for option in options: candidate = cls._canonicalize_option_name(option)@@ -1028,7 +1031,7 @@ class Git(metaclass=_GitMeta): raise UnsafeOptionError( f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." )- if option_char not in clusterable_short_options:+ if option_char not in clusterable_short_options_set: break if not (option.startswith("--") or (options_are_kwargs and len(candidate) > 1)): continue@@ -1133,7 +1136,7 @@ class Git(metaclass=_GitMeta): """List references in a remote repository. :param allow_unsafe_options:- Allow unsafe options, like ``--upload-pack``.+ Allow unsafe options, like ``--upload-pack`` or ``--exec``. """ if not allow_unsafe_options: candidate_options = self._option_candidates(args, kwargs)Candidate 37b102258d139978fa44e5c566c98669bf00ae0ed6775f6a563a391e4eee2a16 · Fix cff1f04d98b9eec696f594fd09c82c06abf808702adb43d1b2fb7b93844e5009
Releases