Skip to content

Commit d0a9007

Browse files
codexByron
authored andcommitted
guard unsafe git command options (GHSA-956x-8gvw-wg5v)
1 parent 20c5e27 commit d0a9007

6 files changed

Lines changed: 123 additions & 3 deletions

File tree

git/cmd.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,11 @@ class Git(metaclass=_GitMeta):
649649

650650
re_unsafe_protocol = re.compile(r"(.+)::.+")
651651

652+
unsafe_git_ls_remote_options = [
653+
# This option allows arbitrary command execution in git-ls-remote.
654+
"--upload-pack",
655+
]
656+
652657
def __getstate__(self) -> Dict[str, Any]:
653658
return slots_to_dict(self, exclude=self._excluded_)
654659

@@ -1030,6 +1035,21 @@ def set_persistent_git_options(self, **kwargs: Any) -> None:
10301035

10311036
self._persistent_git_options = self.transform_kwargs(split_single_char_options=True, **kwargs)
10321037

1038+
def ls_remote(
1039+
self,
1040+
*args: Any,
1041+
allow_unsafe_options: bool = False,
1042+
**kwargs: Any,
1043+
) -> Union[str, bytes, Tuple[int, Union[str, bytes], str], "Git.AutoInterrupt"]:
1044+
"""List references in a remote repository.
1045+
1046+
:param allow_unsafe_options:
1047+
Allow unsafe options, like ``--upload-pack``.
1048+
"""
1049+
if not allow_unsafe_options:
1050+
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_ls_remote_options)
1051+
return self._call_process("ls_remote", *args, **kwargs)
1052+
10331053
@property
10341054
def working_dir(self) -> Union[None, PathLike]:
10351055
""":return: Git directory we are working on"""

git/objects/commit.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ class Commit(base.Object, TraversableIterableObj, Diffable, Serializable):
8686
# INVARIANTS
8787
default_encoding = "UTF-8"
8888

89+
unsafe_git_rev_options = [
90+
# This option can truncate or write arbitrary files before revision parsing.
91+
"--output",
92+
"-o",
93+
]
94+
"""Options to :manpage:`git-rev-list(1)` that can overwrite files."""
95+
8996
type: Literal["commit"] = "commit"
9097

9198
__slots__ = (
@@ -302,6 +309,7 @@ def iter_items(
302309
repo: "Repo",
303310
rev: Union[str, "Commit", "SymbolicReference"],
304311
paths: Union[PathLike, Sequence[PathLike]] = "",
312+
allow_unsafe_options: bool = False,
305313
**kwargs: Any,
306314
) -> Iterator["Commit"]:
307315
R"""Find all commits matching the given criteria.
@@ -330,6 +338,9 @@ def iter_items(
330338
raise ValueError("--pretty cannot be used as parsing expects single sha's only")
331339
# END handle pretty
332340

341+
if not allow_unsafe_options:
342+
Git.check_unsafe_options(options=[str(rev)], unsafe_options=cls.unsafe_git_rev_options)
343+
333344
# Use -- in all cases, to prevent possibility of ambiguous arguments.
334345
# See https://github.com/gitpython-developers/GitPython/issues/264.
335346

git/repo/base.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ class Repo:
161161
https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---configltkeygtltvaluegt
162162
"""
163163

164+
unsafe_git_archive_options = [
165+
# This option allows arbitrary command execution in git-archive.
166+
"--upload-pack",
167+
"--exec",
168+
]
169+
"""Options to :manpage:`git-archive(1)` that can be dangerous."""
170+
171+
unsafe_git_revision_options = [
172+
# This option allows output to be written to arbitrary files before revision parsing.
173+
"--output",
174+
"-o",
175+
]
176+
"""Options to :manpage:`git-rev-list(1)` / :manpage:`git-blame(1)` that can overwrite files."""
177+
164178
# Invariants
165179
config_level: ConfigLevels_Tup = ("system", "user", "global", "repository")
166180
"""Represents the configuration level of a configuration file."""
@@ -775,6 +789,7 @@ def iter_commits(
775789
self,
776790
rev: Union[str, Commit, "SymbolicReference", None] = None,
777791
paths: Union[PathLike, Sequence[PathLike]] = "",
792+
allow_unsafe_options: bool = False,
778793
**kwargs: Any,
779794
) -> Iterator[Commit]:
780795
"""An iterator of :class:`~git.objects.commit.Commit` objects representing the
@@ -792,6 +807,9 @@ def iter_commits(
792807
Arguments to be passed to :manpage:`git-rev-list(1)`.
793808
Common ones are ``max_count`` and ``skip``.
794809
810+
:param allow_unsafe_options:
811+
Allow unsafe options in the revision argument, like ``--output``.
812+
795813
:note:
796814
To receive only commits between two named revisions, use the
797815
``"revA...revB"`` revision specifier.
@@ -802,7 +820,16 @@ def iter_commits(
802820
if rev is None:
803821
rev = self.head.commit
804822

805-
return Commit.iter_items(self, rev, paths, **kwargs)
823+
if not allow_unsafe_options:
824+
Git.check_unsafe_options(options=[str(rev)], unsafe_options=self.unsafe_git_revision_options)
825+
826+
return Commit.iter_items(
827+
self,
828+
rev,
829+
paths,
830+
allow_unsafe_options=allow_unsafe_options,
831+
**kwargs,
832+
)
806833

807834
def merge_base(self, *rev: TBD, **kwargs: Any) -> List[Commit]:
808835
R"""Find the closest common ancestor for the given revision
@@ -1079,7 +1106,9 @@ def active_branch(self) -> Head:
10791106
)
10801107
return active_branch
10811108

1082-
def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> Iterator["BlameEntry"]:
1109+
def blame_incremental(
1110+
self, rev: str | HEAD | None, file: str, allow_unsafe_options: bool = False, **kwargs: Any
1111+
) -> Iterator["BlameEntry"]:
10831112
"""Iterator for blame information for the given file at the given revision.
10841113
10851114
Unlike :meth:`blame`, this does not return the actual file's contents, only a
@@ -1090,6 +1119,9 @@ def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) ->
10901119
uncommitted changes. Otherwise, anything successfully parsed by
10911120
:manpage:`git-rev-parse(1)` is a valid option.
10921121
1122+
:param allow_unsafe_options:
1123+
Allow unsafe options in revision argument, like ``--output``.
1124+
10931125
:return:
10941126
Lazy iterator of :class:`BlameEntry` tuples, where the commit indicates the
10951127
commit to blame for the line, and range indicates a span of line numbers in
@@ -1098,6 +1130,8 @@ def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) ->
10981130
If you combine all line number ranges outputted by this command, you should get
10991131
a continuous range spanning all line numbers in the file.
11001132
"""
1133+
if not allow_unsafe_options:
1134+
Git.check_unsafe_options(options=[str(rev)], unsafe_options=self.unsafe_git_revision_options)
11011135

11021136
data: bytes = self.git.blame(rev, "--", file, p=True, incremental=True, stdout_as_string=False, **kwargs)
11031137
commits: Dict[bytes, Commit] = {}
@@ -1177,6 +1211,7 @@ def blame(
11771211
file: str,
11781212
incremental: bool = False,
11791213
rev_opts: Optional[List[str]] = None,
1214+
allow_unsafe_options: bool = False,
11801215
**kwargs: Any,
11811216
) -> List[List[Commit | List[str | bytes] | None]] | Iterator[BlameEntry] | None:
11821217
"""The blame information for the given file at the given revision.
@@ -1186,6 +1221,9 @@ def blame(
11861221
uncommitted changes. Otherwise, anything successfully parsed by
11871222
:manpage:`git-rev-parse(1)` is a valid option.
11881223
1224+
:param allow_unsafe_options:
1225+
Allow unsafe options in revision argument, like ``--output``.
1226+
11891227
:return:
11901228
list: [git.Commit, list: [<line>]]
11911229
@@ -1195,7 +1233,9 @@ def blame(
11951233
appearance.
11961234
"""
11971235
if incremental:
1198-
return self.blame_incremental(rev, file, **kwargs)
1236+
return self.blame_incremental(rev, file, allow_unsafe_options=allow_unsafe_options, **kwargs)
1237+
if not allow_unsafe_options:
1238+
Git.check_unsafe_options(options=[str(rev)], unsafe_options=self.unsafe_git_revision_options)
11991239
rev_opts = rev_opts or []
12001240
data: bytes = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)
12011241
commits: Dict[str, Commit] = {}
@@ -1583,6 +1623,7 @@ def archive(
15831623
ostream: Union[TextIO, BinaryIO],
15841624
treeish: Optional[str] = None,
15851625
prefix: Optional[str] = None,
1626+
allow_unsafe_options: bool = False,
15861627
**kwargs: Any,
15871628
) -> Repo:
15881629
"""Archive the tree at the given revision.
@@ -1605,6 +1646,9 @@ def archive(
16051646
repository-relative path to a directory or file to place into the archive,
16061647
or a list or tuple of multiple paths.
16071648
1649+
:param allow_unsafe_options:
1650+
Allow unsafe options in ``kwargs``, like ``--exec`` or ``--upload-pack``.
1651+
16081652
:raise git.exc.GitCommandError:
16091653
If something went wrong.
16101654
@@ -1615,6 +1659,8 @@ def archive(
16151659
treeish = self.head.commit
16161660
if prefix and "prefix" not in kwargs:
16171661
kwargs["prefix"] = prefix
1662+
if not allow_unsafe_options:
1663+
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_archive_options)
16181664
kwargs["output_stream"] = ostream
16191665
path = kwargs.pop("path", [])
16201666
path = cast(Union[PathLike, List[PathLike], Tuple[PathLike, ...]], path)

test/test_commit.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from gitdb import IStream
1616

1717
from git import Actor, Commit, Repo
18+
from git.exc import UnsafeOptionError
1819
from git.objects.util import tzoffset, utc
1920
from git.repo.fun import touch
2021

@@ -288,6 +289,11 @@ def test_iter_items(self):
288289
# pretty not allowed.
289290
self.assertRaises(ValueError, Commit.iter_items, self.rorepo, "master", pretty="raw")
290291

292+
def test_iter_items_rejects_unsafe_revision(self):
293+
with tempfile.TemporaryDirectory() as tdir:
294+
marker = osp.join(tdir, "pwn")
295+
self.assertRaises(UnsafeOptionError, Commit.iter_items, self.rorepo, f"--output={marker}")
296+
291297
def test_rev_list_bisect_all(self):
292298
"""
293299
'git rev-list --bisect-all' returns additional information

test/test_remote.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,26 @@ def test_push_unsafe_options_allowed(self, rw_repo):
10071007
assert tmp_file.exists()
10081008
tmp_file.unlink()
10091009

1010+
@with_rw_repo("HEAD")
1011+
def test_ls_remote_unsafe_options(self, rw_repo):
1012+
with tempfile.TemporaryDirectory() as tdir:
1013+
tmp_dir = Path(tdir)
1014+
tmp_file = tmp_dir / "pwn"
1015+
unsafe_options = [{"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"}]
1016+
for unsafe_option in unsafe_options:
1017+
with self.assertRaises(UnsafeOptionError):
1018+
rw_repo.git.ls_remote(".", **unsafe_option)
1019+
1020+
@with_rw_repo("HEAD")
1021+
def test_ls_remote_unsafe_options_allowed(self, rw_repo):
1022+
with tempfile.TemporaryDirectory() as tdir:
1023+
tmp_dir = Path(tdir)
1024+
tmp_file = tmp_dir / "pwn"
1025+
unsafe_options = [{"upload-pack": f"touch {tmp_file}"}]
1026+
for unsafe_option in unsafe_options:
1027+
with self.assertRaises(GitCommandError):
1028+
rw_repo.git.ls_remote(".", **unsafe_option, allow_unsafe_options=True)
1029+
10101030
@with_rw_and_rw_remote_repo("0.1.6")
10111031
def test_fetch_unsafe_branch_name(self, rw_repo, remote_repo):
10121032
# Create branch with a name containing a NBSP

test/test_repo.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
Submodule,
3838
Tree,
3939
)
40+
from git.exc import UnsafeOptionError
4041
from git.exc import BadObject
4142
from git.repo.fun import touch
4243
from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree
@@ -422,6 +423,17 @@ def test_archive(self):
422423
assert stream.tell()
423424
os.remove(stream.name) # Do it this way so we can inspect the file on failure.
424425

426+
def test_archive_rejects_unsafe_options(self):
427+
with tempfile.NamedTemporaryFile("w", suffix="-unsafe-option") as output_marker:
428+
with self.assertRaises(UnsafeOptionError):
429+
self.rorepo.archive(io.BytesIO(), "0.1.6", exec=f"touch {output_marker.name}")
430+
431+
def test_iter_commits_rejects_unsafe_revision(self):
432+
with tempfile.TemporaryDirectory() as tdir:
433+
target = osp.join(tdir, "pwn")
434+
with self.assertRaises(UnsafeOptionError):
435+
list(self.rorepo.iter_commits(f"--output={target}", max_count=1))
436+
425437
@mock.patch.object(Git, "_call_process")
426438
def test_should_display_blame_information(self, git):
427439
git.return_value = fixture("blame")
@@ -471,6 +483,11 @@ def test_blame_real(self):
471483
assert c, "Should have executed at least one blame command"
472484
assert nml, "There should at least be one blame commit that contains multiple lines"
473485

486+
def test_blame_rejects_unsafe_revision(self):
487+
with tempfile.NamedTemporaryFile("w", suffix="-unsafe-option") as output_marker:
488+
with self.assertRaises(UnsafeOptionError):
489+
self.rorepo.blame(f"--output={output_marker.name}", "README.md")
490+
474491
@mock.patch.object(Git, "_call_process")
475492
def test_blame_incremental(self, git):
476493
# Loop over two fixtures, create a test fixture for 2.11.1+ syntax.

0 commit comments

Comments
 (0)