Skip to content

Commit 550d74e

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

6 files changed

Lines changed: 146 additions & 3 deletions

File tree

git/cmd.py

Lines changed: 21 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,22 @@ 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+
unsafe_options = list(kwargs.keys()) + [str(arg) for arg in args if arg is not None]
1051+
Git.check_unsafe_options(options=unsafe_options, unsafe_options=self.unsafe_git_ls_remote_options)
1052+
return self._call_process("ls_remote", *args, **kwargs)
1053+
10331054
@property
10341055
def working_dir(self) -> Union[None, PathLike]:
10351056
""":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: 56 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,18 @@ 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(
825+
options=[str(rev)] + list(kwargs.keys()), unsafe_options=self.unsafe_git_revision_options
826+
)
827+
828+
return Commit.iter_items(
829+
self,
830+
rev,
831+
paths,
832+
allow_unsafe_options=allow_unsafe_options,
833+
**kwargs,
834+
)
806835

807836
def merge_base(self, *rev: TBD, **kwargs: Any) -> List[Commit]:
808837
R"""Find the closest common ancestor for the given revision
@@ -1079,7 +1108,9 @@ def active_branch(self) -> Head:
10791108
)
10801109
return active_branch
10811110

1082-
def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> Iterator["BlameEntry"]:
1111+
def blame_incremental(
1112+
self, rev: str | HEAD | None, file: str, allow_unsafe_options: bool = False, **kwargs: Any
1113+
) -> Iterator["BlameEntry"]:
10831114
"""Iterator for blame information for the given file at the given revision.
10841115
10851116
Unlike :meth:`blame`, this does not return the actual file's contents, only a
@@ -1090,6 +1121,9 @@ def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) ->
10901121
uncommitted changes. Otherwise, anything successfully parsed by
10911122
:manpage:`git-rev-parse(1)` is a valid option.
10921123
1124+
:param allow_unsafe_options:
1125+
Allow unsafe options in revision argument, like ``--output``.
1126+
10931127
:return:
10941128
Lazy iterator of :class:`BlameEntry` tuples, where the commit indicates the
10951129
commit to blame for the line, and range indicates a span of line numbers in
@@ -1098,6 +1132,10 @@ def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) ->
10981132
If you combine all line number ranges outputted by this command, you should get
10991133
a continuous range spanning all line numbers in the file.
11001134
"""
1135+
if not allow_unsafe_options:
1136+
Git.check_unsafe_options(
1137+
options=[str(rev)] + list(kwargs.keys()), unsafe_options=self.unsafe_git_revision_options
1138+
)
11011139

11021140
data: bytes = self.git.blame(rev, "--", file, p=True, incremental=True, stdout_as_string=False, **kwargs)
11031141
commits: Dict[bytes, Commit] = {}
@@ -1177,6 +1215,7 @@ def blame(
11771215
file: str,
11781216
incremental: bool = False,
11791217
rev_opts: Optional[List[str]] = None,
1218+
allow_unsafe_options: bool = False,
11801219
**kwargs: Any,
11811220
) -> List[List[Commit | List[str | bytes] | None]] | Iterator[BlameEntry] | None:
11821221
"""The blame information for the given file at the given revision.
@@ -1186,6 +1225,9 @@ def blame(
11861225
uncommitted changes. Otherwise, anything successfully parsed by
11871226
:manpage:`git-rev-parse(1)` is a valid option.
11881227
1228+
:param allow_unsafe_options:
1229+
Allow unsafe options in revision argument, like ``--output``.
1230+
11891231
:return:
11901232
list: [git.Commit, list: [<line>]]
11911233
@@ -1195,7 +1237,12 @@ def blame(
11951237
appearance.
11961238
"""
11971239
if incremental:
1198-
return self.blame_incremental(rev, file, **kwargs)
1240+
return self.blame_incremental(rev, file, allow_unsafe_options=allow_unsafe_options, **kwargs)
1241+
if not allow_unsafe_options:
1242+
rev_opts = rev_opts or []
1243+
Git.check_unsafe_options(
1244+
options=[str(rev)] + rev_opts + list(kwargs.keys()), unsafe_options=self.unsafe_git_revision_options
1245+
)
11991246
rev_opts = rev_opts or []
12001247
data: bytes = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)
12011248
commits: Dict[str, Commit] = {}
@@ -1583,6 +1630,7 @@ def archive(
15831630
ostream: Union[TextIO, BinaryIO],
15841631
treeish: Optional[str] = None,
15851632
prefix: Optional[str] = None,
1633+
allow_unsafe_options: bool = False,
15861634
**kwargs: Any,
15871635
) -> Repo:
15881636
"""Archive the tree at the given revision.
@@ -1605,6 +1653,9 @@ def archive(
16051653
repository-relative path to a directory or file to place into the archive,
16061654
or a list or tuple of multiple paths.
16071655
1656+
:param allow_unsafe_options:
1657+
Allow unsafe options in ``kwargs``, like ``--exec`` or ``--upload-pack``.
1658+
16081659
:raise git.exc.GitCommandError:
16091660
If something went wrong.
16101661
@@ -1615,6 +1666,8 @@ def archive(
16151666
treeish = self.head.commit
16161667
if prefix and "prefix" not in kwargs:
16171668
kwargs["prefix"] = prefix
1669+
if not allow_unsafe_options:
1670+
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_archive_options)
16181671
kwargs["output_stream"] = ostream
16191672
path = kwargs.pop("path", [])
16201673
path = cast(Union[PathLike, List[PathLike], Tuple[PathLike, ...]], path)

test/test_commit.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import copy
77
from datetime import datetime
88
from io import BytesIO
9+
import tempfile
910
import os.path as osp
1011
import re
1112
import sys
@@ -15,6 +16,7 @@
1516
from gitdb import IStream
1617

1718
from git import Actor, Commit, Repo
19+
from git.exc import UnsafeOptionError
1820
from git.objects.util import tzoffset, utc
1921
from git.repo.fun import touch
2022

@@ -288,6 +290,11 @@ def test_iter_items(self):
288290
# pretty not allowed.
289291
self.assertRaises(ValueError, Commit.iter_items, self.rorepo, "master", pretty="raw")
290292

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

test/test_remote.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,30 @@ 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+
with self.assertRaises(UnsafeOptionError):
1020+
rw_repo.git.ls_remote(f"--upload-pack={tmp_file}", ".")
1021+
with self.assertRaises(UnsafeOptionError):
1022+
rw_repo.git.ls_remote("--upload-pack", "touch", ".")
1023+
1024+
@with_rw_repo("HEAD")
1025+
def test_ls_remote_unsafe_options_allowed(self, rw_repo):
1026+
with tempfile.TemporaryDirectory() as tdir:
1027+
tmp_dir = Path(tdir)
1028+
tmp_file = tmp_dir / "pwn"
1029+
unsafe_options = [{"upload-pack": f"touch {tmp_file}"}]
1030+
for unsafe_option in unsafe_options:
1031+
with self.assertRaises(GitCommandError):
1032+
rw_repo.git.ls_remote(".", **unsafe_option, allow_unsafe_options=True)
1033+
10101034
@with_rw_and_rw_remote_repo("0.1.6")
10111035
def test_fetch_unsafe_branch_name(self, rw_repo, remote_repo):
10121036
# Create branch with a name containing a NBSP

test/test_repo.py

Lines changed: 27 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,21 @@ 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+
491+
def test_blame_rejects_unsafe_options(self):
492+
with tempfile.NamedTemporaryFile("w", suffix="-unsafe-option") as output_marker:
493+
with self.assertRaises(UnsafeOptionError):
494+
self.rorepo.blame("HEAD", "README.md", output=f"touch {output_marker.name}")
495+
496+
def test_blame_rejects_unsafe_rev_opts(self):
497+
with tempfile.NamedTemporaryFile("w", suffix="-unsafe-option") as output_marker:
498+
with self.assertRaises(UnsafeOptionError):
499+
self.rorepo.blame("HEAD", "README.md", rev_opts=[f"--output={output_marker.name}"])
500+
474501
@mock.patch.object(Git, "_call_process")
475502
def test_blame_incremental(self, git):
476503
# Loop over two fixtures, create a test fixture for 2.11.1+ syntax.

0 commit comments

Comments
 (0)