GeezerVison is showing github.com — fix: complete safety checks in Git command wrappers · gitpython-developers/GitPython@c480bd5
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: git/cmd.py +14-1Lines changed: 14 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -645,7 +645,8 @@ class Git(metaclass=_GitMeta): | 645 | 645 | "_version_info_token", | 646 | 646 | ) | 647 | 647 |
| 648 | | - re_unsafe_protocol = re.compile(r"(.+)::.+") | | 648 | + # Match Git's leading transport selector, including an empty helper name. | | 649 | + re_unsafe_protocol = re.compile(r"([A-Za-z0-9][A-Za-z0-9+.-]*|)::") | 649 | 650 |
| 650 | 651 | unsafe_git_ls_remote_options = [ | 651 | 652 | # This option allows arbitrary command execution in git-ls-remote. | @@ -1129,16 +1130,28 @@ def ls_remote( | 1129 | 1130 | self, | 1130 | 1131 | *args: Any, | 1131 | 1132 | allow_unsafe_options: bool = False, | | 1133 | + allow_unsafe_protocols: bool = False, | 1132 | 1134 | **kwargs: Any, | 1133 | 1135 | ) -> Union[str, bytes, Tuple[int, Union[str, bytes], str], "Git.AutoInterrupt"]: | 1134 | 1136 | """List references in a remote repository. | 1135 | 1137 |
| 1136 | 1138 | :param allow_unsafe_options: | 1137 | 1139 | Allow unsafe options, like ``--upload-pack`` or ``--exec``. | | 1140 | +
| | 1141 | + :param allow_unsafe_protocols: | | 1142 | + Allow unsafe protocols to be used, like ``ext``. Positional arguments | | 1143 | + and split short-option values are checked. | 1138 | 1144 | """ | 1139 | 1145 | if not allow_unsafe_options: | 1140 | 1146 | candidate_options = self._option_candidates(args, kwargs) | 1141 | 1147 | Git.check_unsafe_options(options=candidate_options, unsafe_options=self.unsafe_git_ls_remote_options) | | 1148 | + if not allow_unsafe_protocols: | | 1149 | + protocol_args = list(args) | | 1150 | + if kwargs.get("split_single_char_options", True): | | 1151 | + # Split short-option values can become the URL after parsing earlier options. | | 1152 | + protocol_args.extend(value for key, value in kwargs.items() if len(key) == 1) | | 1153 | + for arg in self._unpack_args(protocol_args): | | 1154 | + self.check_unsafe_protocols(arg) | 1142 | 1155 | return self._call_process("ls_remote", *args, **kwargs) | 1143 | 1156 |
| 1144 | 1157 | @property | |
Collapse file Copy file name to clipboardExpand all lines: git/index/base.py +10Lines changed: 10 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -1093,6 +1093,7 @@ def move( | 1093 | 1093 | self, | 1094 | 1094 | items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]], | 1095 | 1095 | skip_errors: bool = False, | | 1096 | + allow_unsafe_options: bool = False, | 1096 | 1097 | **kwargs: Any, | 1097 | 1098 | ) -> List[Tuple[str, str]]: | 1098 | 1099 | """Rename/move the items, whereas the last item is considered the destination of | @@ -1113,6 +1114,10 @@ def move( | 1113 | 1114 | If ``True``, errors such as ones resulting from missing source files will be | 1114 | 1115 | skipped. | 1115 | 1116 |
| | 1117 | + :param allow_unsafe_options: | | 1118 | + Allow unsafe options such as ``--pathspec-from-file`` to be passed to | | 1119 | + :manpage:`git-mv(1)`. | | 1120 | +
| 1116 | 1121 | :param kwargs: | 1117 | 1122 | Additional arguments you would like to pass to :manpage:`git-mv(1)`, such as | 1118 | 1123 | ``dry_run`` or ``force``. | @@ -1129,6 +1134,11 @@ def move( | 1129 | 1134 | :raise git.exc.GitCommandError: | 1130 | 1135 | If git could not handle your request. | 1131 | 1136 | """ | | 1137 | + if not allow_unsafe_options: | | 1138 | + Git.check_unsafe_options( | | 1139 | + options=Git._option_candidates([], kwargs), | | 1140 | + unsafe_options=Git.unsafe_git_pathspec_from_file_options, | | 1141 | + ) | 1132 | 1142 | args = [] | 1133 | 1143 | if skip_errors: | 1134 | 1144 | args.append("-k") | |
Collapse file Copy file name to clipboardExpand all lines: git/repo/base.py +13-3Lines changed: 13 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -892,14 +892,17 @@ def iter_commits( | 892 | 892 | **kwargs, | 893 | 893 | ) | 894 | 894 |
| 895 | | - def merge_base(self, *rev: TBD, **kwargs: Any) -> List[Commit]: | | 895 | + def merge_base(self, *rev: TBD, allow_unsafe_options: bool = False, **kwargs: Any) -> List[Commit]: | 896 | 896 | R"""Find the closest common ancestor for the given revision | 897 | 897 | (:class:`~git.objects.commit.Commit`\s, :class:`~git.refs.tag.Tag`\s, | 898 | 898 | :class:`~git.refs.reference.Reference`\s, etc.). | 899 | 899 |
| 900 | 900 | :param rev: | 901 | 901 | At least two revs to find the common ancestor for. | 902 | 902 |
| | 903 | + :param allow_unsafe_options: | | 904 | + Allow unsafe options in the revision arguments, like ``--output``. | | 905 | +
| 903 | 906 | :param kwargs: | 904 | 907 | Additional arguments to be passed to the ``repo.git.merge_base()`` command | 905 | 908 | which does all the work. | @@ -912,18 +915,25 @@ def merge_base(self, *rev: TBD, **kwargs: Any) -> List[Commit]: | 912 | 915 |
| 913 | 916 | :raise ValueError: | 914 | 917 | If fewer than two revisions are provided. | | 918 | +
| | 919 | + :raise git.exc.GitCommandError: | | 920 | + If git fails for a reason other than having no common merge base. | 915 | 921 | """ | 916 | 922 | if len(rev) < 2: | 917 | 923 | raise ValueError("Please specify at least two revs, got only %i" % len(rev)) | 918 | 924 | # END handle input | 919 | 925 |
| | 926 | + if not allow_unsafe_options: | | 927 | + Git.check_unsafe_options( | | 928 | + options=Git._option_candidates(rev, kwargs), unsafe_options=self.unsafe_git_revision_options | | 929 | + ) | | 930 | + | 920 | 931 | res: List[Commit] = [] | 921 | 932 | try: | 922 | 933 | lines: List[str] = self.git.merge_base(*rev, **kwargs).splitlines() | 923 | 934 | except GitCommandError as err: | 924 | | - if err.status == 128: | | 935 | + if err.status != 1: | 925 | 936 | raise | 926 | | - # END handle invalid rev | 927 | 937 | # Status code 1 is returned if there is no merge-base. | 928 | 938 | # (See: https://github.com/git/git/blob/v2.44.0/builtin/merge-base.c#L19) | 929 | 939 | return res | |
Collapse file Copy file name to clipboard +151Lines changed: 151 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +"""Command wrappers apply safety checks before starting Git.""" | | 2 | + | | 3 | +from pathlib import Path | | 4 | +from unittest import mock | | 5 | + | | 6 | +import pytest | | 7 | + | | 8 | +from git import Actor, Git, GitCommandError, Repo | | 9 | +from git.exc import UnsafeOptionError, UnsafeProtocolError | | 10 | + | | 11 | + | | 12 | +@pytest.mark.parametrize("allow_unsafe_options", [False, True]) | | 13 | +@pytest.mark.parametrize( | | 14 | + "args, kwargs", | | 15 | + [ | | 16 | + (("ext::helper",), {}), | | 17 | + (("ext::",), {}), | | 18 | + (("custom::address",), {}), | | 19 | + (("1custom+v2.test-name::address",), {}), | | 20 | + (("::address",), {}), | | 21 | + (("custom::\naddress",), {}), | | 22 | + ((["--refs", ("ext::helper",)],), {}), | | 23 | + ((None, "--", "ext::helper", "HEAD"), {}), | | 24 | + ((Path("ext::helper"),), {}), | | 25 | + ((), {"q": "ext::helper"}), | | 26 | + ((), {"h": ["ext::helper"]}), | | 27 | + ((), {"-": "ext::helper"}), | | 28 | + ((), {"o": [True, "ext::helper"]}), | | 29 | + (("--server-option",), {"o": "ext::helper", "insert_kwargs_after": "--server-option"}), | | 30 | + ], | | 31 | +) | | 32 | +def test_ls_remote_rejects_unsafe_protocols(args, kwargs, allow_unsafe_options): | | 33 | + with mock.patch.object(Git, "execute", side_effect=AssertionError("Git must not run")) as run: | | 34 | + with pytest.raises(UnsafeProtocolError): | | 35 | + Git().ls_remote(*args, allow_unsafe_options=allow_unsafe_options, **kwargs) | | 36 | + run.assert_not_called() | | 37 | + | | 38 | + | | 39 | +@pytest.mark.parametrize( | | 40 | + "args, kwargs", | | 41 | + [ | | 42 | + ((), {}), | | 43 | + ((None,), {}), | | 44 | + (("origin", "HEAD"), {"h": True}), | | 45 | + (("https://example.com/repo.git",), {}), | | 46 | + | | 47 | + (("origin",), {"o": "key=value"}), | | 48 | + (("origin",), {"server_option": "key::value"}), | | 49 | + ], | | 50 | +) | | 51 | +def test_ls_remote_preserves_safe_arguments(args, kwargs): | | 52 | + with mock.patch.object(Git, "execute", return_value="refs") as run: | | 53 | + assert Git().ls_remote(*args, **kwargs) == "refs" | | 54 | + run.assert_called_once() | | 55 | + | | 56 | + | | 57 | +@pytest.mark.parametrize( | | 58 | + "url", | | 59 | + [ | | 60 | + "https://[::1]/repo.git", | | 61 | + "ssh://git@[2001:db8::1]/repo.git", | | 62 | + "https://example.com/repo::name", | | 63 | + | | 64 | + "./repo::name", | | 65 | + ], | | 66 | +) | | 67 | +def test_ls_remote_preserves_double_colons_outside_helper_selector(url): | | 68 | + assert Git().ls_remote(url, get_url=True) == url | | 69 | + | | 70 | + | | 71 | +def test_ls_remote_unsafe_opt_ins_are_independent(): | | 72 | + with mock.patch.object(Git, "execute", return_value="refs") as run: | | 73 | + with pytest.raises(UnsafeOptionError): | | 74 | + Git().ls_remote("origin", upload_pack="helper", allow_unsafe_protocols=True) | | 75 | + run.assert_not_called() | | 76 | + assert ( | | 77 | + Git().ls_remote("ext::helper", upload_pack="helper", allow_unsafe_protocols=True, allow_unsafe_options=True) | | 78 | + == "refs" | | 79 | + ) | | 80 | + run.assert_called_once_with([Git.GIT_PYTHON_GIT_EXECUTABLE, "ls-remote", "--upload-pack=helper", "ext::helper"]) | | 81 | + | | 82 | + | | 83 | +@pytest.mark.parametrize("allow_unsafe_options", [False, True]) | | 84 | +@pytest.mark.parametrize( | | 85 | + "revs, kwargs", | | 86 | + [ | | 87 | + (("HEAD", "--output=unused"), {}), | | 88 | + (("HEAD", ["--out=unused"]), {}), | | 89 | + (("HEAD", "-ounused"), {}), | | 90 | + (("HEAD", "HEAD"), {"output": "unused"}), | | 91 | + (("HEAD", "HEAD"), {"out": "unused"}), | | 92 | + (("HEAD", "HEAD"), {"o": "unused"}), | | 93 | + ], | | 94 | +) | | 95 | +def test_merge_base_checks_unsafe_options(tmp_path, revs, kwargs, allow_unsafe_options): | | 96 | + repo = Repo.init(tmp_path) | | 97 | + with mock.patch.object(Git, "execute", return_value="") as run: | | 98 | + if allow_unsafe_options: | | 99 | + assert repo.merge_base(*revs, allow_unsafe_options=True, **kwargs) == [] | | 100 | + run.assert_called_once() | | 101 | + assert "--allow-unsafe-options" not in run.call_args[0][0] | | 102 | + else: | | 103 | + with pytest.raises(UnsafeOptionError): | | 104 | + repo.merge_base(*revs, **kwargs) | | 105 | + run.assert_not_called() | | 106 | + | | 107 | + | | 108 | +@pytest.mark.parametrize("status", [-9, 2, 128, 129]) | | 109 | +def test_merge_base_propagates_errors(tmp_path, status): | | 110 | + repo = Repo.init(tmp_path) | | 111 | + error = GitCommandError("git merge-base", status) | | 112 | + with mock.patch.object(Git, "execute", side_effect=error): | | 113 | + with pytest.raises(GitCommandError) as raised: | | 114 | + repo.merge_base("HEAD", "HEAD") | | 115 | + assert raised.value is error | | 116 | + | | 117 | + | | 118 | +def test_merge_base_distinguishes_unrelated_history_from_invalid_options(tmp_path): | | 119 | + repo = Repo.init(tmp_path) | | 120 | + | | 121 | + first = repo.index.commit("first", author=actor, committer=actor) | | 122 | + second = repo.index.commit("second", parent_commits=[], head=False, author=actor, committer=actor) | | 123 | + assert repo.merge_base(first, first) == [first] | | 124 | + assert repo.merge_base(first, second) == [] | | 125 | + with pytest.raises(GitCommandError) as raised: | | 126 | + repo.merge_base(first, second, invalid_option=True) | | 127 | + assert raised.value.status == 129 | | 128 | + | | 129 | + | | 130 | +@pytest.mark.parametrize("allow_unsafe_options", [False, True]) | | 131 | +@pytest.mark.parametrize("option", ["pathspec_from_file", "pathspec-from-file", "pathspec_from"]) | | 132 | +@pytest.mark.parametrize("dry_run", [False, True]) | | 133 | +def test_move_checks_unsafe_options(tmp_path, option, dry_run, allow_unsafe_options): | | 134 | + repo = Repo.init(tmp_path) | | 135 | + with mock.patch.object(Git, "execute", return_value="Renaming source to destination\n") as run: | | 136 | + kwargs = {option: "unused", "dry_run": dry_run} | | 137 | + if allow_unsafe_options: | | 138 | + assert repo.index.move(["source", "destination"], True, allow_unsafe_options=True, **kwargs) == [ | | 139 | + ("source", "destination") | | 140 | + ] | | 141 | + assert run.call_count == (1 if dry_run else 2) | | 142 | + for call in run.call_args_list: | | 143 | + argv = call[0][0] | | 144 | + assert "-k" in argv | | 145 | + assert f"--{option.replace('_', '-')}=unused" in argv | | 146 | + assert "--allow-unsafe-options" not in argv | | 147 | + assert argv[-3:] == ["--", "source", "destination"] | | 148 | + else: | | 149 | + with pytest.raises(UnsafeOptionError): | | 150 | + repo.index.move(["source", "destination"], **kwargs) | | 151 | + run.assert_not_called() |
|