Skip to content

Commit 91821bd

Browse files
Byroncodex
andcommitted
Block file-reading Git options
<!-- agent --> Reject file-reading options passed to blame, diff, and tag APIs. Inspect positional values after resolving aliases, recognize unsafe options behind command-specific short-flag clusters, and retain the explicit allow_unsafe_options escape hatch. This closes GHSA-5xxx-qhh7-9287 and GHSA-3wxw-xv34-2frg and covers the adjacent diff order-file sink. Regression tests cover long, short, and clustered options, incremental blame, tag path/reference positionals, the ref keyword alias, both diff entry points, and preservation of diff pickaxe behavior. Git baseline: cf5497b14c; git-blame, git-diff, and git-tag document the relevant file-input options. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 <codex@openai.com>
1 parent 4ba9ce6 commit 91821bd

8 files changed

Lines changed: 62 additions & 17 deletions

File tree

git/cmd.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -976,7 +976,9 @@ def _canonicalize_option_name(cls, option: str) -> str:
976976
return dashify(option_tokens[0])
977977

978978
@classmethod
979-
def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None:
979+
def check_unsafe_options(
980+
cls, options: List[str], unsafe_options: List[str], clusterable_short_options: str = "46flnqsv"
981+
) -> None:
980982
"""Raise :class:`~git.exc.UnsafeOptionError` for blocked option spellings.
981983
982984
In addition to exact matches, this rejects abbreviated long options accepted
@@ -1011,7 +1013,7 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) ->
10111013
# These value-less Git flags can be clustered before another short option
10121014
# (for example, ``-fuVALUE``). Stop at any other character because it may
10131015
# begin an attached value, as ``o`` does in the safe option ``-oupstream``.
1014-
clusterable_short_options = frozenset("46flnqsv")
1016+
clusterable_short_options_set = frozenset(clusterable_short_options)
10151017
options_are_kwargs = all(not option.startswith("-") for option in options)
10161018
for option in options:
10171019
candidate = cls._canonicalize_option_name(option)
@@ -1028,7 +1030,7 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) ->
10281030
raise UnsafeOptionError(
10291031
f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it."
10301032
)
1031-
if option_char not in clusterable_short_options:
1033+
if option_char not in clusterable_short_options_set:
10321034
break
10331035
if not (option.startswith("--") or (options_are_kwargs and len(candidate) > 1)):
10341036
continue

git/diff.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@ def diff(
220220
to be read and diffed.
221221
222222
:param allow_unsafe_options:
223-
If ``True``, allow options such as ``--output`` that can write to arbitrary
224-
filesystem paths.
223+
If ``True``, allow options such as ``--output`` and ``-O`` that can write to
224+
or read from arbitrary filesystem paths.
225225
226226
:param kwargs:
227227
Additional arguments passed to :manpage:`git-diff(1)`, such as ``R=True`` to
@@ -238,7 +238,8 @@ def diff(
238238
if not allow_unsafe_options:
239239
Git.check_unsafe_options(
240240
options=Git._option_candidates([other], kwargs),
241-
unsafe_options=self.repo.unsafe_git_revision_options,
241+
unsafe_options=self.repo.unsafe_git_diff_options,
242+
clusterable_short_options="46abceflmnpqrstuvwzBCDMNRW",
242243
)
243244

244245
args: List[Union[PathLike, Diffable]] = []

git/index/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1567,7 +1567,8 @@ def diff(
15671567
if not allow_unsafe_options:
15681568
Git.check_unsafe_options(
15691569
options=Git._option_candidates([other], kwargs),
1570-
unsafe_options=self.repo.unsafe_git_revision_options,
1570+
unsafe_options=self.repo.unsafe_git_diff_options,
1571+
clusterable_short_options="46abceflmnpqrstuvwzBCDMNRW",
15711572
)
15721573

15731574
# Only run if we are the default repository index.

git/refs/tag.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,16 @@ def create(
134134
:return:
135135
A new :class:`TagReference`.
136136
"""
137+
if "ref" in kwargs and kwargs["ref"]:
138+
reference = kwargs["ref"]
139+
137140
if not allow_unsafe_options:
138141
Git.check_unsafe_options(
139-
options=Git._option_candidates([], kwargs),
142+
options=Git._option_candidates([path, reference], kwargs),
140143
unsafe_options=cls.unsafe_git_tag_options,
144+
clusterable_short_options="46adefilnqsv",
141145
)
142146

143-
if "ref" in kwargs and kwargs["ref"]:
144-
reference = kwargs["ref"]
145-
146147
if "message" in kwargs and kwargs["message"]:
147148
kwargs["m"] = kwargs["message"]
148149
del kwargs["message"]

git/repo/base.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,18 @@ class Repo:
199199
"-o",
200200
]
201201

202+
unsafe_git_blame_options = unsafe_git_revision_options + [
203+
# These options read from arbitrary files and expose their contents through blame output.
204+
"--contents",
205+
"-S",
206+
"--ignore-revs-file",
207+
]
208+
209+
unsafe_git_diff_options = unsafe_git_revision_options + [
210+
# Reads caller-controlled order patterns from an arbitrary file.
211+
"-O",
212+
]
213+
202214
# Invariants
203215
config_level: ConfigLevels_Tup = ("system", "user", "global", "repository")
204216
"""Represents the configuration level of a configuration file."""
@@ -1149,7 +1161,7 @@ def blame_incremental(
11491161
:manpage:`git-rev-parse(1)` is a valid option.
11501162
11511163
:param allow_unsafe_options:
1152-
Allow unsafe options in revision argument, like ``--output``.
1164+
Allow unsafe options in revision argument, like ``--output`` or ``--contents``.
11531165
11541166
:return:
11551167
Lazy iterator of :class:`BlameEntry` tuples, where the commit indicates the
@@ -1161,7 +1173,9 @@ def blame_incremental(
11611173
"""
11621174
if not allow_unsafe_options:
11631175
Git.check_unsafe_options(
1164-
options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options
1176+
options=Git._option_candidates([rev], kwargs),
1177+
unsafe_options=self.unsafe_git_blame_options,
1178+
clusterable_short_options="46bceflnpqstvw",
11651179
)
11661180

11671181
data: bytes = self.git.blame(rev, "--", file, p=True, incremental=True, stdout_as_string=False, **kwargs)
@@ -1253,7 +1267,7 @@ def blame(
12531267
:manpage:`git-rev-parse(1)` is a valid option.
12541268
12551269
:param allow_unsafe_options:
1256-
Allow unsafe options in revision argument, like ``--output``.
1270+
Allow unsafe options in revision argument, like ``--output`` or ``--contents``.
12571271
12581272
:return:
12591273
list: [git.Commit, list: [<line>]]
@@ -1269,7 +1283,8 @@ def blame(
12691283
if not allow_unsafe_options:
12701284
Git.check_unsafe_options(
12711285
options=Git._option_candidates([rev, rev_opts_list], kwargs),
1272-
unsafe_options=self.unsafe_git_revision_options,
1286+
unsafe_options=self.unsafe_git_blame_options,
1287+
clusterable_short_options="46bceflnpqstvw",
12731288
)
12741289
data: bytes = self.git.blame(rev, *rev_opts_list, "--", file, p=True, stdout_as_string=False, **kwargs)
12751290
commits: Dict[str, Commit] = {}

test/test_diff.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,11 +376,21 @@ def test_diff_submodule(self):
376376
def test_diff_rejects_unsafe_output_options(self):
377377
commit = self.rorepo.head.commit
378378

379+
commit.diff(S="needle")
380+
379381
calls = (
380382
lambda target: commit.diff(output=target),
381383
lambda target: commit.diff(other=f"--output={target}"),
384+
lambda target: commit.diff(O=target),
385+
lambda target: commit.diff(other=f"-pO{target}"),
386+
lambda target: commit.diff(other=f"-uO{target}"),
387+
lambda target: commit.diff(other=f"-DO{target}"),
382388
lambda target: self.rorepo.index.diff(NULL_TREE, output=target),
383389
lambda target: self.rorepo.index.diff(f"--output={target}"),
390+
lambda target: self.rorepo.index.diff(NULL_TREE, O=target),
391+
lambda target: self.rorepo.index.diff(f"-pO{target}"),
392+
lambda target: self.rorepo.index.diff(f"-uO{target}"),
393+
lambda target: self.rorepo.index.diff(f"-DO{target}"),
384394
)
385395
for index, call in enumerate(calls):
386396
target = osp.join(self.repo_dir, f"diff-output-{index}")

test/test_refs.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ def test_tag_create_rejects_unsafe_file_options(self, rw_repo):
7070
with self.assertRaises(UnsafeOptionError):
7171
TagReference.create(rw_repo, f"unsafe-{index}", **option)
7272

73+
for args in (
74+
("unsafe-reference", f"--file={message.name}"),
75+
(f"--file={message.name}", "HEAD"),
76+
(f"-eF{message.name}", "HEAD"),
77+
(f"-iF{message.name}", "HEAD"),
78+
):
79+
with self.assertRaises(UnsafeOptionError):
80+
TagReference.create(rw_repo, *args)
81+
82+
with self.assertRaises(UnsafeOptionError):
83+
TagReference.create(rw_repo, "unsafe-ref-kwarg", ref=f"--file={message.name}")
84+
7385
tag = TagReference.create(rw_repo, "allowed-file", F=message.name, allow_unsafe_options=True)
7486
self.assertEqual(tag.tag.message, "private tag message")
7587

test/test_repo.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,8 +590,11 @@ def test_blame_real(self):
590590
def test_blame_rejects_unsafe_revision(self):
591591
with tempfile.TemporaryDirectory() as tdir:
592592
output_marker = osp.join(tdir, "pwn")
593-
with self.assertRaises(UnsafeOptionError):
594-
self.rorepo.blame(f"--output={output_marker}", "README.md")
593+
for option in ("--output", "--contents", "-S", "-wS", "--ignore-revs-file"):
594+
with self.assertRaises(UnsafeOptionError):
595+
self.rorepo.blame(f"{option}={output_marker}", "README.md")
596+
with self.assertRaises(UnsafeOptionError):
597+
list(self.rorepo.blame_incremental(f"{option}={output_marker}", "README.md"))
595598
assert not osp.exists(output_marker)
596599

597600
def test_blame_rejects_unsafe_options(self):

0 commit comments

Comments
 (0)