Skip to content

Commit 678a8fe

Browse files
authored
Merge pull request #1521 from stsewd/block-insecure-options
Block insecure options and protocols by default
2 parents ae6a6e4 + f4f2658 commit 678a8fe

File tree

10 files changed

+752
-21
lines changed

10 files changed

+752
-21
lines changed

AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@ Contributors are:
5050
-Patrick Gerard
5151
-Luke Twist <[email protected]>
5252
-Joseph Hale <me _at_ jhale.dev>
53+
-Santos Gallegos <stsewd _at_ proton.me>
5354
Portions derived from other open source works and are clearly marked.

git/cmd.py

+49-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# This module is part of GitPython and is released under
55
# the BSD License: http://www.opensource.org/licenses/bsd-license.php
66
from __future__ import annotations
7+
import re
78
from contextlib import contextmanager
89
import io
910
import logging
@@ -24,7 +25,7 @@
2425
from git.exc import CommandError
2526
from git.util import is_cygwin_git, cygpath, expand_path, remove_password_if_present
2627

27-
from .exc import GitCommandError, GitCommandNotFound
28+
from .exc import GitCommandError, GitCommandNotFound, UnsafeOptionError, UnsafeProtocolError
2829
from .util import (
2930
LazyMixin,
3031
stream_copy,
@@ -262,6 +263,8 @@ class Git(LazyMixin):
262263

263264
_excluded_ = ("cat_file_all", "cat_file_header", "_version_info")
264265

266+
re_unsafe_protocol = re.compile("(.+)::.+")
267+
265268
def __getstate__(self) -> Dict[str, Any]:
266269
return slots_to_dict(self, exclude=self._excluded_)
267270

@@ -454,6 +457,48 @@ def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None) -> PathLike:
454457
url = url.replace("\\\\", "\\").replace("\\", "/")
455458
return url
456459

460+
@classmethod
461+
def check_unsafe_protocols(cls, url: str) -> None:
462+
"""
463+
Check for unsafe protocols.
464+
465+
Apart from the usual protocols (http, git, ssh),
466+
Git allows "remote helpers" that have the form `<transport>::<address>`,
467+
one of these helpers (`ext::`) can be used to invoke any arbitrary command.
468+
469+
See:
470+
471+
- https://git-scm.com/docs/gitremote-helpers
472+
- https://git-scm.com/docs/git-remote-ext
473+
"""
474+
match = cls.re_unsafe_protocol.match(url)
475+
if match:
476+
protocol = match.group(1)
477+
raise UnsafeProtocolError(
478+
f"The `{protocol}::` protocol looks suspicious, use `allow_unsafe_protocols=True` to allow it."
479+
)
480+
481+
@classmethod
482+
def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None:
483+
"""
484+
Check for unsafe options.
485+
486+
Some options that are passed to `git <command>` can be used to execute
487+
arbitrary commands, this are blocked by default.
488+
"""
489+
# Options can be of the form `foo` or `--foo bar` `--foo=bar`,
490+
# so we need to check if they start with "--foo" or if they are equal to "foo".
491+
bare_unsafe_options = [
492+
option.lstrip("-")
493+
for option in unsafe_options
494+
]
495+
for option in options:
496+
for unsafe_option, bare_option in zip(unsafe_options, bare_unsafe_options):
497+
if option.startswith(unsafe_option) or option == bare_option:
498+
raise UnsafeOptionError(
499+
f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it."
500+
)
501+
457502
class AutoInterrupt(object):
458503
"""Kill/Interrupt the stored process instance once this instance goes out of scope. It is
459504
used to prevent processes piling up in case iterators stop reading.
@@ -1148,12 +1193,12 @@ def transform_kwargs(self, split_single_char_options: bool = True, **kwargs: Any
11481193
return args
11491194

11501195
@classmethod
1151-
def __unpack_args(cls, arg_list: Sequence[str]) -> List[str]:
1196+
def _unpack_args(cls, arg_list: Sequence[str]) -> List[str]:
11521197

11531198
outlist = []
11541199
if isinstance(arg_list, (list, tuple)):
11551200
for arg in arg_list:
1156-
outlist.extend(cls.__unpack_args(arg))
1201+
outlist.extend(cls._unpack_args(arg))
11571202
else:
11581203
outlist.append(str(arg_list))
11591204

@@ -1238,7 +1283,7 @@ def _call_process(
12381283
# Prepare the argument list
12391284

12401285
opt_args = self.transform_kwargs(**opts_kwargs)
1241-
ext_args = self.__unpack_args([a for a in args if a is not None])
1286+
ext_args = self._unpack_args([a for a in args if a is not None])
12421287

12431288
if insert_after_this_arg is None:
12441289
args_list = opt_args + ext_args

git/exc.py

+8
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ class NoSuchPathError(GitError, OSError):
3737
"""Thrown if a path could not be access by the system."""
3838

3939

40+
class UnsafeProtocolError(GitError):
41+
"""Thrown if unsafe protocols are passed without being explicitly allowed."""
42+
43+
44+
class UnsafeOptionError(GitError):
45+
"""Thrown if unsafe options are passed without being explicitly allowed."""
46+
47+
4048
class CommandError(GitError):
4149
"""Base class for exceptions thrown at every stage of `Popen()` execution.
4250

git/objects/submodule/base.py

+33-3
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,16 @@ def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> Path
272272
# end
273273

274274
@classmethod
275-
def _clone_repo(cls, repo: "Repo", url: str, path: PathLike, name: str, **kwargs: Any) -> "Repo":
275+
def _clone_repo(
276+
cls,
277+
repo: "Repo",
278+
url: str,
279+
path: PathLike,
280+
name: str,
281+
allow_unsafe_options: bool = False,
282+
allow_unsafe_protocols: bool = False,
283+
**kwargs: Any,
284+
) -> "Repo":
276285
""":return: Repo instance of newly cloned repository
277286
:param repo: our parent repository
278287
:param url: url to clone from
@@ -289,7 +298,13 @@ def _clone_repo(cls, repo: "Repo", url: str, path: PathLike, name: str, **kwargs
289298
module_checkout_path = osp.join(str(repo.working_tree_dir), path)
290299
# end
291300

292-
clone = git.Repo.clone_from(url, module_checkout_path, **kwargs)
301+
clone = git.Repo.clone_from(
302+
url,
303+
module_checkout_path,
304+
allow_unsafe_options=allow_unsafe_options,
305+
allow_unsafe_protocols=allow_unsafe_protocols,
306+
**kwargs,
307+
)
293308
if cls._need_gitfile_submodules(repo.git):
294309
cls._write_git_file_and_module_config(module_checkout_path, module_abspath)
295310
# end
@@ -359,6 +374,8 @@ def add(
359374
depth: Union[int, None] = None,
360375
env: Union[Mapping[str, str], None] = None,
361376
clone_multi_options: Union[Sequence[TBD], None] = None,
377+
allow_unsafe_options: bool = False,
378+
allow_unsafe_protocols: bool = False,
362379
) -> "Submodule":
363380
"""Add a new submodule to the given repository. This will alter the index
364381
as well as the .gitmodules file, but will not create a new commit.
@@ -475,7 +492,16 @@ def add(
475492
kwargs["multi_options"] = clone_multi_options
476493

477494
# _clone_repo(cls, repo, url, path, name, **kwargs):
478-
mrepo = cls._clone_repo(repo, url, path, name, env=env, **kwargs)
495+
mrepo = cls._clone_repo(
496+
repo,
497+
url,
498+
path,
499+
name,
500+
env=env,
501+
allow_unsafe_options=allow_unsafe_options,
502+
allow_unsafe_protocols=allow_unsafe_protocols,
503+
**kwargs,
504+
)
479505
# END verify url
480506

481507
## See #525 for ensuring git urls in config-files valid under Windows.
@@ -520,6 +546,8 @@ def update(
520546
keep_going: bool = False,
521547
env: Union[Mapping[str, str], None] = None,
522548
clone_multi_options: Union[Sequence[TBD], None] = None,
549+
allow_unsafe_options: bool = False,
550+
allow_unsafe_protocols: bool = False,
523551
) -> "Submodule":
524552
"""Update the repository of this submodule to point to the checkout
525553
we point at with the binsha of this instance.
@@ -643,6 +671,8 @@ def update(
643671
n=True,
644672
env=env,
645673
multi_options=clone_multi_options,
674+
allow_unsafe_options=allow_unsafe_options,
675+
allow_unsafe_protocols=allow_unsafe_protocols,
646676
)
647677
# END handle dry-run
648678
progress.update(

git/remote.py

+63-7
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,23 @@ class Remote(LazyMixin, IterableObj):
539539
__slots__ = ("repo", "name", "_config_reader")
540540
_id_attribute_ = "name"
541541

542+
unsafe_git_fetch_options = [
543+
# This option allows users to execute arbitrary commands.
544+
# https://git-scm.com/docs/git-fetch#Documentation/git-fetch.txt---upload-packltupload-packgt
545+
"--upload-pack",
546+
]
547+
unsafe_git_pull_options = [
548+
# This option allows users to execute arbitrary commands.
549+
# https://git-scm.com/docs/git-pull#Documentation/git-pull.txt---upload-packltupload-packgt
550+
"--upload-pack"
551+
]
552+
unsafe_git_push_options = [
553+
# This option allows users to execute arbitrary commands.
554+
# https://git-scm.com/docs/git-push#Documentation/git-push.txt---execltgit-receive-packgt
555+
"--receive-pack",
556+
"--exec",
557+
]
558+
542559
def __init__(self, repo: "Repo", name: str) -> None:
543560
"""Initialize a remote instance
544561
@@ -615,7 +632,9 @@ def iter_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> Iterator["Remote
615632
yield Remote(repo, section[lbound + 1 : rbound])
616633
# END for each configuration section
617634

618-
def set_url(self, new_url: str, old_url: Optional[str] = None, **kwargs: Any) -> "Remote":
635+
def set_url(
636+
self, new_url: str, old_url: Optional[str] = None, allow_unsafe_protocols: bool = False, **kwargs: Any
637+
) -> "Remote":
619638
"""Configure URLs on current remote (cf command git remote set_url)
620639
621640
This command manages URLs on the remote.
@@ -624,15 +643,17 @@ def set_url(self, new_url: str, old_url: Optional[str] = None, **kwargs: Any) ->
624643
:param old_url: when set, replaces this URL with new_url for the remote
625644
:return: self
626645
"""
646+
if not allow_unsafe_protocols:
647+
Git.check_unsafe_protocols(new_url)
627648
scmd = "set-url"
628649
kwargs["insert_kwargs_after"] = scmd
629650
if old_url:
630-
self.repo.git.remote(scmd, self.name, new_url, old_url, **kwargs)
651+
self.repo.git.remote(scmd, "--", self.name, new_url, old_url, **kwargs)
631652
else:
632-
self.repo.git.remote(scmd, self.name, new_url, **kwargs)
653+
self.repo.git.remote(scmd, "--", self.name, new_url, **kwargs)
633654
return self
634655

635-
def add_url(self, url: str, **kwargs: Any) -> "Remote":
656+
def add_url(self, url: str, allow_unsafe_protocols: bool = False, **kwargs: Any) -> "Remote":
636657
"""Adds a new url on current remote (special case of git remote set_url)
637658
638659
This command adds new URLs to a given remote, making it possible to have
@@ -641,7 +662,7 @@ def add_url(self, url: str, **kwargs: Any) -> "Remote":
641662
:param url: string being the URL to add as an extra remote URL
642663
:return: self
643664
"""
644-
return self.set_url(url, add=True)
665+
return self.set_url(url, add=True, allow_unsafe_protocols=allow_unsafe_protocols)
645666

646667
def delete_url(self, url: str, **kwargs: Any) -> "Remote":
647668
"""Deletes a new url on current remote (special case of git remote set_url)
@@ -733,7 +754,7 @@ def stale_refs(self) -> IterableList[Reference]:
733754
return out_refs
734755

735756
@classmethod
736-
def create(cls, repo: "Repo", name: str, url: str, **kwargs: Any) -> "Remote":
757+
def create(cls, repo: "Repo", name: str, url: str, allow_unsafe_protocols: bool = False, **kwargs: Any) -> "Remote":
737758
"""Create a new remote to the given repository
738759
:param repo: Repository instance that is to receive the new remote
739760
:param name: Desired name of the remote
@@ -743,7 +764,10 @@ def create(cls, repo: "Repo", name: str, url: str, **kwargs: Any) -> "Remote":
743764
:raise GitCommandError: in case an origin with that name already exists"""
744765
scmd = "add"
745766
kwargs["insert_kwargs_after"] = scmd
746-
repo.git.remote(scmd, name, Git.polish_url(url), **kwargs)
767+
url = Git.polish_url(url)
768+
if not allow_unsafe_protocols:
769+
Git.check_unsafe_protocols(url)
770+
repo.git.remote(scmd, "--", name, url, **kwargs)
747771
return cls(repo, name)
748772

749773
# add is an alias
@@ -925,6 +949,8 @@ def fetch(
925949
progress: Union[RemoteProgress, None, "UpdateProgress"] = None,
926950
verbose: bool = True,
927951
kill_after_timeout: Union[None, float] = None,
952+
allow_unsafe_protocols: bool = False,
953+
allow_unsafe_options: bool = False,
928954
**kwargs: Any,
929955
) -> IterableList[FetchInfo]:
930956
"""Fetch the latest changes for this remote
@@ -967,6 +993,14 @@ def fetch(
967993
else:
968994
args = [refspec]
969995

996+
if not allow_unsafe_protocols:
997+
for ref in args:
998+
if ref:
999+
Git.check_unsafe_protocols(ref)
1000+
1001+
if not allow_unsafe_options:
1002+
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_fetch_options)
1003+
9701004
proc = self.repo.git.fetch(
9711005
"--", self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs
9721006
)
@@ -980,6 +1014,8 @@ def pull(
9801014
refspec: Union[str, List[str], None] = None,
9811015
progress: Union[RemoteProgress, "UpdateProgress", None] = None,
9821016
kill_after_timeout: Union[None, float] = None,
1017+
allow_unsafe_protocols: bool = False,
1018+
allow_unsafe_options: bool = False,
9831019
**kwargs: Any,
9841020
) -> IterableList[FetchInfo]:
9851021
"""Pull changes from the given branch, being the same as a fetch followed
@@ -994,6 +1030,15 @@ def pull(
9941030
# No argument refspec, then ensure the repo's config has a fetch refspec.
9951031
self._assert_refspec()
9961032
kwargs = add_progress(kwargs, self.repo.git, progress)
1033+
1034+
refspec = Git._unpack_args(refspec or [])
1035+
if not allow_unsafe_protocols:
1036+
for ref in refspec:
1037+
Git.check_unsafe_protocols(ref)
1038+
1039+
if not allow_unsafe_options:
1040+
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_pull_options)
1041+
9971042
proc = self.repo.git.pull(
9981043
"--", self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs
9991044
)
@@ -1007,6 +1052,8 @@ def push(
10071052
refspec: Union[str, List[str], None] = None,
10081053
progress: Union[RemoteProgress, "UpdateProgress", Callable[..., RemoteProgress], None] = None,
10091054
kill_after_timeout: Union[None, float] = None,
1055+
allow_unsafe_protocols: bool = False,
1056+
allow_unsafe_options: bool = False,
10101057
**kwargs: Any,
10111058
) -> PushInfoList:
10121059
"""Push changes from source branch in refspec to target branch in refspec.
@@ -1037,6 +1084,15 @@ def push(
10371084
be 0.
10381085
Call ``.raise_if_error()`` on the returned object to raise on any failure."""
10391086
kwargs = add_progress(kwargs, self.repo.git, progress)
1087+
1088+
refspec = Git._unpack_args(refspec or [])
1089+
if not allow_unsafe_protocols:
1090+
for ref in refspec:
1091+
Git.check_unsafe_protocols(ref)
1092+
1093+
if not allow_unsafe_options:
1094+
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_push_options)
1095+
10401096
proc = self.repo.git.push(
10411097
"--",
10421098
self,

0 commit comments

Comments
 (0)