Skip to content

Commit b1b585c

Browse files
codexByron
authored andcommitted
Block unsafe checkout-index and tag file options
GHSA-3f7w-8rr8-f37f reports that caller-controlled options forwarded by IndexFile.checkout() and TagReference.create() can make Git write to or read from arbitrary filesystem paths. Add regression coverage proving the unsafe options are rejected by default while preserving the explicit allow_unsafe_options escape hatch. Reuse GitPython's existing option candidate normalization and unsafe-option guard at both public API boundaries. Git baseline: a23bace963d508bd96983cc637131392d3face18. Documentation/git-checkout-index.adoc defines --prefix as prepending an output path, and Documentation/git-tag.adoc defines -F/--file as reading a tag message from a file. Validation: - uv run --with-editable .[test] pytest -q (focused security tests: 3 passed) - uv run --with-editable .[test] pytest -q test/test_index.py test/test_refs.py (56 passed, 2 unrelated fixture failures due to absent master branch) - uvx ruff check (touched files) - uvx ruff format --check (touched files) - git diff --check
1 parent 07e8055 commit b1b585c

4 files changed

Lines changed: 48 additions & 0 deletions

File tree

git/index/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable):
130130
index directly before operating on it using the git command.
131131
"""
132132

133+
unsafe_git_checkout_index_options = ["--prefix"]
134+
133135
__slots__ = ("repo", "version", "entries", "_extension_data", "_file_path")
134136

135137
_VERSION = 2
@@ -1212,6 +1214,7 @@ def checkout(
12121214
paths: Union[None, Iterable[PathLike]] = None,
12131215
force: bool = False,
12141216
fprogress: Callable = lambda *args: None,
1217+
allow_unsafe_options: bool = False,
12151218
**kwargs: Any,
12161219
) -> Union[None, Iterator[PathLike], Sequence[PathLike]]:
12171220
"""Check out the given paths or all files from the version known to the index
@@ -1238,6 +1241,9 @@ def checkout(
12381241
no explicit paths are given. Otherwise progress information will be send
12391242
prior and after a file has been checked out.
12401243
1244+
:param allow_unsafe_options:
1245+
Allow unsafe options, such as ``--prefix``.
1246+
12411247
:param kwargs:
12421248
Additional arguments to be passed to :manpage:`git-checkout-index(1)`.
12431249
@@ -1261,6 +1267,12 @@ def checkout(
12611267
i.e. if you want :manpage:`git-checkout(1)`-like behaviour, use
12621268
``head.checkout`` instead of ``index.checkout``.
12631269
"""
1270+
if not allow_unsafe_options:
1271+
Git.check_unsafe_options(
1272+
options=Git._option_candidates([], kwargs),
1273+
unsafe_options=self.unsafe_git_checkout_index_options,
1274+
)
1275+
12641276
args = ["--index"]
12651277
if force:
12661278
args.append("--force")

git/refs/tag.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from typing import Any, TYPE_CHECKING, Type, Union
1818

19+
from git.cmd import Git
1920
from git.types import AnyGitObject, PathLike
2021

2122
if TYPE_CHECKING:
@@ -42,6 +43,8 @@ class TagReference(Reference):
4243

4344
__slots__ = ()
4445

46+
unsafe_git_tag_options = ["--file", "-F"]
47+
4548
_common_default = "tags"
4649
_common_path_default = Reference._common_path_default + "/" + _common_default
4750

@@ -92,6 +95,7 @@ def create(
9295
reference: Union[str, "SymbolicReference"] = "HEAD",
9396
logmsg: Union[str, None] = None,
9497
force: bool = False,
98+
allow_unsafe_options: bool = False,
9599
**kwargs: Any,
96100
) -> "TagReference":
97101
"""Create a new tag reference.
@@ -121,12 +125,21 @@ def create(
121125
:param force:
122126
If ``True``, force creation of a tag even though that tag already exists.
123127
128+
:param allow_unsafe_options:
129+
Allow unsafe options, such as ``--file``.
130+
124131
:param kwargs:
125132
Additional keyword arguments to be passed to :manpage:`git-tag(1)`.
126133
127134
:return:
128135
A new :class:`TagReference`.
129136
"""
137+
if not allow_unsafe_options:
138+
Git.check_unsafe_options(
139+
options=Git._option_candidates([], kwargs),
140+
unsafe_options=cls.unsafe_git_tag_options,
141+
)
142+
130143
if "ref" in kwargs and kwargs["ref"]:
131144
reference = kwargs["ref"]
132145

test/test_index.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
HookExecutionError,
3232
InvalidGitRepositoryError,
3333
UnmergedEntriesError,
34+
UnsafeOptionError,
3435
)
3536
from git.index.fun import hook_path, run_commit_hook
3637
from git.index.typ import BaseIndexEntry, IndexEntry
@@ -202,6 +203,15 @@ def _make_hook(git_dir, name, content, make_exec=True):
202203

203204
@ddt.ddt
204205
class TestIndex(TestBase):
206+
@with_rw_repo("HEAD")
207+
def test_checkout_rejects_unsafe_prefix(self, rw_repo):
208+
with tempfile.TemporaryDirectory() as target:
209+
with self.assertRaises(UnsafeOptionError):
210+
rw_repo.index.checkout(prefix=f"{target}/")
211+
212+
rw_repo.index.checkout(prefix=f"{target}/", allow_unsafe_options=True)
213+
self.assertTrue(osp.isfile(osp.join(target, "CHANGES")))
214+
205215
def __init__(self, *args):
206216
super().__init__(*args)
207217
self._reset_progress()

test/test_refs.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
SymbolicReference,
2424
TagReference,
2525
)
26+
from git.exc import UnsafeOptionError
2627
from git.objects.tag import TagObject
2728
import git.refs as refs
2829
from git.util import Actor
@@ -60,6 +61,18 @@ def test_from_path(self):
6061
# Check remoteness
6162
assert Reference(self.rorepo, "refs/remotes/origin").is_remote()
6263

64+
@with_rw_repo("HEAD")
65+
def test_tag_create_rejects_unsafe_file_options(self, rw_repo):
66+
with tempfile.NamedTemporaryFile("w", encoding="utf-8") as message:
67+
message.write("private tag message")
68+
message.flush()
69+
for index, option in enumerate(({"F": message.name}, {"file": message.name})):
70+
with self.assertRaises(UnsafeOptionError):
71+
TagReference.create(rw_repo, f"unsafe-{index}", **option)
72+
73+
tag = TagReference.create(rw_repo, "allowed-file", F=message.name, allow_unsafe_options=True)
74+
self.assertEqual(tag.tag.message, "private tag message")
75+
6376
def test_from_pathlike(self):
6477
# Should be able to create any reference directly.
6578
for ref_type in (Reference, Head, TagReference, RemoteReference):

0 commit comments

Comments
 (0)