diff --git a/git/index/base.py b/git/index/base.py index 8d7dbfc1f..4dd1fcb1d 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -130,6 +130,8 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): index directly before operating on it using the git command. """ + unsafe_git_checkout_index_options = ["--prefix"] + __slots__ = ("repo", "version", "entries", "_extension_data", "_file_path") _VERSION = 2 @@ -1212,6 +1214,7 @@ def checkout( paths: Union[None, Iterable[PathLike]] = None, force: bool = False, fprogress: Callable = lambda *args: None, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> Union[None, Iterator[PathLike], Sequence[PathLike]]: """Check out the given paths or all files from the version known to the index @@ -1238,6 +1241,9 @@ def checkout( no explicit paths are given. Otherwise progress information will be send prior and after a file has been checked out. + :param allow_unsafe_options: + Allow unsafe options, such as ``--prefix``. + :param kwargs: Additional arguments to be passed to :manpage:`git-checkout-index(1)`. @@ -1261,6 +1267,12 @@ def checkout( i.e. if you want :manpage:`git-checkout(1)`-like behaviour, use ``head.checkout`` instead of ``index.checkout``. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=self.unsafe_git_checkout_index_options, + ) + args = ["--index"] if force: args.append("--force") diff --git a/git/refs/tag.py b/git/refs/tag.py index 4525b09cb..055722e3b 100644 --- a/git/refs/tag.py +++ b/git/refs/tag.py @@ -16,6 +16,7 @@ from typing import Any, TYPE_CHECKING, Type, Union +from git.cmd import Git from git.types import AnyGitObject, PathLike if TYPE_CHECKING: @@ -42,6 +43,8 @@ class TagReference(Reference): __slots__ = () + unsafe_git_tag_options = ["--file", "-F"] + _common_default = "tags" _common_path_default = Reference._common_path_default + "/" + _common_default @@ -92,6 +95,7 @@ def create( reference: Union[str, "SymbolicReference"] = "HEAD", logmsg: Union[str, None] = None, force: bool = False, + allow_unsafe_options: bool = False, **kwargs: Any, ) -> "TagReference": """Create a new tag reference. @@ -121,12 +125,21 @@ def create( :param force: If ``True``, force creation of a tag even though that tag already exists. + :param allow_unsafe_options: + Allow unsafe options, such as ``--file``. + :param kwargs: Additional keyword arguments to be passed to :manpage:`git-tag(1)`. :return: A new :class:`TagReference`. """ + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([], kwargs), + unsafe_options=cls.unsafe_git_tag_options, + ) + if "ref" in kwargs and kwargs["ref"]: reference = kwargs["ref"] diff --git a/git/repo/base.py b/git/repo/base.py index ae99ed5f9..6594101f3 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -151,8 +151,10 @@ class Repo: "-c", # Can install hooks that execute during clone: "--template", + # Fetches from an additional caller-controlled URI: + "--bundle-uri", ] - """Options to :manpage:`git-clone(1)` that allow arbitrary commands to be executed. + """Options to :manpage:`git-clone(1)` that permit unsafe command execution or I/O. The ``--upload-pack``/``-u`` option allows users to execute arbitrary commands directly: @@ -164,6 +166,11 @@ class Repo: The ``--template`` option can install hooks that execute during clone: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---templatetemplate-directory + + The ``--bundle-uri`` option fetches from an additional URI before fetching from the + clone URL. An untrusted value can therefore make Git access local files or + unintended network resources: + https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---bundle-uriuri """ unsafe_git_archive_options = [ @@ -172,6 +179,10 @@ class Repo: # Writes output to a caller-controlled filesystem path. "--output", "-o", + # Reads from a caller-controlled filesystem path: + "--add-file", + # Injects a caller-controlled path and contents: + "--add-virtual-file", ] unsafe_git_revision_options = [ diff --git a/test/test_clone.py b/test/test_clone.py index 0c8155944..5af67613a 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -132,6 +132,7 @@ def test_clone_unsafe_options(self, rw_repo): "-cprotocol.ext.allow=always", "-vcprotocol.ext.allow=always", f"--template={tmp_dir}", + f"--bundle-uri=file://{tmp_dir}", ] for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): @@ -147,6 +148,7 @@ def test_clone_unsafe_options(self, rw_repo): {"conf": "protocol.ext.allow=always"}, {"c": "protocol.ext.allow=always"}, {"template": tmp_dir}, + {"bundle_uri": f"file://{tmp_dir}"}, ] for unsafe_option in unsafe_options: with self.assertRaises(UnsafeOptionError): diff --git a/test/test_index.py b/test/test_index.py index 881dec19f..3ad5a457f 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -31,6 +31,7 @@ HookExecutionError, InvalidGitRepositoryError, UnmergedEntriesError, + UnsafeOptionError, ) from git.index.fun import hook_path, run_commit_hook from git.index.typ import BaseIndexEntry, IndexEntry @@ -202,6 +203,15 @@ def _make_hook(git_dir, name, content, make_exec=True): @ddt.ddt class TestIndex(TestBase): + @with_rw_repo("HEAD") + def test_checkout_rejects_unsafe_prefix(self, rw_repo): + with tempfile.TemporaryDirectory() as target: + with self.assertRaises(UnsafeOptionError): + rw_repo.index.checkout(prefix=f"{target}/") + + rw_repo.index.checkout(prefix=f"{target}/", allow_unsafe_options=True) + self.assertTrue(osp.isfile(osp.join(target, "CHANGES"))) + def __init__(self, *args): super().__init__(*args) self._reset_progress() diff --git a/test/test_refs.py b/test/test_refs.py index d77b34eba..6481b54a8 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -23,6 +23,7 @@ SymbolicReference, TagReference, ) +from git.exc import UnsafeOptionError from git.objects.tag import TagObject import git.refs as refs from git.util import Actor @@ -60,6 +61,18 @@ def test_from_path(self): # Check remoteness assert Reference(self.rorepo, "refs/remotes/origin").is_remote() + @with_rw_repo("HEAD") + def test_tag_create_rejects_unsafe_file_options(self, rw_repo): + with tempfile.NamedTemporaryFile("w", encoding="utf-8") as message: + message.write("private tag message") + message.flush() + for index, option in enumerate(({"F": message.name}, {"file": message.name})): + with self.assertRaises(UnsafeOptionError): + TagReference.create(rw_repo, f"unsafe-{index}", **option) + + tag = TagReference.create(rw_repo, "allowed-file", F=message.name, allow_unsafe_options=True) + self.assertEqual(tag.tag.message, "private tag message") + def test_from_pathlike(self): # Should be able to create any reference directly. for ref_type in (Reference, Head, TagReference, RemoteReference): diff --git a/test/test_repo.py b/test/test_repo.py index 27246db88..7c7f1dd34 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -433,6 +433,10 @@ def test_archive_rejects_unsafe_options(self): with self.assertRaises(UnsafeOptionError): self.rorepo.archive(io.BytesIO(), "0.1.6", output=output_marker) assert not osp.exists(output_marker) + with self.assertRaises(UnsafeOptionError): + self.rorepo.archive(io.BytesIO(), "0.1.6", add_file=output_marker) + with self.assertRaises(UnsafeOptionError): + self.rorepo.archive(io.BytesIO(), "0.1.6", add_virtual_file="file:content") def test_archive_rejects_unsafe_remote_protocol(self): with tempfile.TemporaryDirectory() as tdir: