Skip to content

Commit e6108c7

Browse files
committed
Block unsafe options and protocols by default
1 parent 2625ed9 commit e6108c7

File tree

5 files changed

+160
-36
lines changed

5 files changed

+160
-36
lines changed

Diff for: git/cmd.py

+46-1
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_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_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.

Diff for: git/exc.py

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

3939

40-
class UnsafeOptionsUsedError(GitError):
41-
"""Thrown if unsafe protocols or options are passed without overridding."""
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."""
4246

4347

4448
class CommandError(GitError):

Diff for: git/remote.py

+67-6
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,23 @@ class Remote(LazyMixin, IterableObj):
535535
__slots__ = ("repo", "name", "_config_reader")
536536
_id_attribute_ = "name"
537537

538+
unsafe_git_fetch_options = [
539+
# This option allows users to execute arbitrary commands.
540+
# https://git-scm.com/docs/git-fetch#Documentation/git-fetch.txt---upload-packltupload-packgt
541+
"--upload-pack",
542+
]
543+
unsafe_git_pull_options = [
544+
# This option allows users to execute arbitrary commands.
545+
# https://git-scm.com/docs/git-pull#Documentation/git-pull.txt---upload-packltupload-packgt
546+
"--upload-pack"
547+
]
548+
unsafe_git_push_options = [
549+
# This option allows users to execute arbitrary commands.
550+
# https://git-scm.com/docs/git-push#Documentation/git-push.txt---execltgit-receive-packgt
551+
"--receive-pack",
552+
"--exec",
553+
]
554+
538555
def __init__(self, repo: "Repo", name: str) -> None:
539556
"""Initialize a remote instance
540557
@@ -611,7 +628,9 @@ def iter_items(cls, repo: "Repo", *args: Any, **kwargs: Any) -> Iterator["Remote
611628
yield Remote(repo, section[lbound + 1 : rbound])
612629
# END for each configuration section
613630

614-
def set_url(self, new_url: str, old_url: Optional[str] = None, **kwargs: Any) -> "Remote":
631+
def set_url(
632+
self, new_url: str, old_url: Optional[str] = None, allow_unsafe_protocols: bool = False, **kwargs: Any
633+
) -> "Remote":
615634
"""Configure URLs on current remote (cf command git remote set_url)
616635
617636
This command manages URLs on the remote.
@@ -620,15 +639,17 @@ def set_url(self, new_url: str, old_url: Optional[str] = None, **kwargs: Any) ->
620639
:param old_url: when set, replaces this URL with new_url for the remote
621640
:return: self
622641
"""
642+
if not allow_unsafe_protocols:
643+
Git.check_unsafe_protocols(new_url)
623644
scmd = "set-url"
624645
kwargs["insert_kwargs_after"] = scmd
625646
if old_url:
626-
self.repo.git.remote(scmd, self.name, new_url, old_url, **kwargs)
647+
self.repo.git.remote(scmd, "--", self.name, new_url, old_url, **kwargs)
627648
else:
628-
self.repo.git.remote(scmd, self.name, new_url, **kwargs)
649+
self.repo.git.remote(scmd, "--", self.name, new_url, **kwargs)
629650
return self
630651

631-
def add_url(self, url: str, **kwargs: Any) -> "Remote":
652+
def add_url(self, url: str, allow_unsafe_protocols: bool = False, **kwargs: Any) -> "Remote":
632653
"""Adds a new url on current remote (special case of git remote set_url)
633654
634655
This command adds new URLs to a given remote, making it possible to have
@@ -637,6 +658,8 @@ def add_url(self, url: str, **kwargs: Any) -> "Remote":
637658
:param url: string being the URL to add as an extra remote URL
638659
:return: self
639660
"""
661+
if not allow_unsafe_protocols:
662+
Git.check_unsafe_protocols(url)
640663
return self.set_url(url, add=True)
641664

642665
def delete_url(self, url: str, **kwargs: Any) -> "Remote":
@@ -729,7 +752,7 @@ def stale_refs(self) -> IterableList[Reference]:
729752
return out_refs
730753

731754
@classmethod
732-
def create(cls, repo: "Repo", name: str, url: str, **kwargs: Any) -> "Remote":
755+
def create(cls, repo: "Repo", name: str, url: str, allow_unsafe_protocols: bool = False, **kwargs: Any) -> "Remote":
733756
"""Create a new remote to the given repository
734757
:param repo: Repository instance that is to receive the new remote
735758
:param name: Desired name of the remote
@@ -739,7 +762,10 @@ def create(cls, repo: "Repo", name: str, url: str, **kwargs: Any) -> "Remote":
739762
:raise GitCommandError: in case an origin with that name already exists"""
740763
scmd = "add"
741764
kwargs["insert_kwargs_after"] = scmd
742-
repo.git.remote(scmd, name, Git.polish_url(url), **kwargs)
765+
url = Git.polish_url(url)
766+
if not allow_unsafe_protocols:
767+
Git.check_unsafe_protocols(url)
768+
repo.git.remote(scmd, "--", name, url, **kwargs)
743769
return cls(repo, name)
744770

745771
# add is an alias
@@ -921,6 +947,8 @@ def fetch(
921947
progress: Union[RemoteProgress, None, "UpdateProgress"] = None,
922948
verbose: bool = True,
923949
kill_after_timeout: Union[None, float] = None,
950+
allow_unsafe_protocols: bool = False,
951+
allow_unsafe_options: bool = False,
924952
**kwargs: Any,
925953
) -> IterableList[FetchInfo]:
926954
"""Fetch the latest changes for this remote
@@ -963,6 +991,14 @@ def fetch(
963991
else:
964992
args = [refspec]
965993

994+
if not allow_unsafe_protocols:
995+
for ref in args:
996+
if ref:
997+
Git.check_unsafe_protocols(ref)
998+
999+
if not allow_unsafe_options:
1000+
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_fetch_options)
1001+
9661002
proc = self.repo.git.fetch(
9671003
"--", self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs
9681004
)
@@ -976,6 +1012,8 @@ def pull(
9761012
refspec: Union[str, List[str], None] = None,
9771013
progress: Union[RemoteProgress, "UpdateProgress", None] = None,
9781014
kill_after_timeout: Union[None, float] = None,
1015+
allow_unsafe_protocols: bool = False,
1016+
allow_unsafe_options: bool = False,
9791017
**kwargs: Any,
9801018
) -> IterableList[FetchInfo]:
9811019
"""Pull changes from the given branch, being the same as a fetch followed
@@ -990,6 +1028,16 @@ def pull(
9901028
# No argument refspec, then ensure the repo's config has a fetch refspec.
9911029
self._assert_refspec()
9921030
kwargs = add_progress(kwargs, self.repo.git, progress)
1031+
1032+
if not allow_unsafe_protocols and refspec:
1033+
if isinstance(refspec, str):
1034+
Git.check_unsafe_protocols(refspec)
1035+
else:
1036+
for ref in refspec:
1037+
Git.check_unsafe_protocols(ref)
1038+
if not allow_unsafe_options:
1039+
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_pull_options)
1040+
9931041
proc = self.repo.git.pull(
9941042
"--", self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs
9951043
)
@@ -1003,6 +1051,8 @@ def push(
10031051
refspec: Union[str, List[str], None] = None,
10041052
progress: Union[RemoteProgress, "UpdateProgress", Callable[..., RemoteProgress], None] = None,
10051053
kill_after_timeout: Union[None, float] = None,
1054+
allow_unsafe_protocols: bool = False,
1055+
allow_unsafe_options: bool = False,
10061056
**kwargs: Any,
10071057
) -> IterableList[PushInfo]:
10081058
"""Push changes from source branch in refspec to target branch in refspec.
@@ -1033,6 +1083,17 @@ def push(
10331083
If the operation fails completely, the length of the returned IterableList will
10341084
be 0."""
10351085
kwargs = add_progress(kwargs, self.repo.git, progress)
1086+
1087+
if not allow_unsafe_protocols and refspec:
1088+
if isinstance(refspec, str):
1089+
Git.check_unsafe_protocols(refspec)
1090+
else:
1091+
for ref in refspec:
1092+
Git.check_unsafe_protocols(ref)
1093+
1094+
if not allow_unsafe_options:
1095+
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_push_options)
1096+
10361097
proc = self.repo.git.push(
10371098
"--",
10381099
self,

Diff for: git/repo/base.py

+38-25
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
GitCommandError,
2626
InvalidGitRepositoryError,
2727
NoSuchPathError,
28-
UnsafeOptionsUsedError,
2928
)
3029
from git.index import IndexFile
3130
from git.objects import Submodule, RootModule, Commit
@@ -133,7 +132,18 @@ class Repo(object):
133132
re_envvars = re.compile(r"(\$(\{\s?)?[a-zA-Z_]\w*(\}\s?)?|%\s?[a-zA-Z_]\w*\s?%)")
134133
re_author_committer_start = re.compile(r"^(author|committer)")
135134
re_tab_full_line = re.compile(r"^\t(.*)$")
136-
re_config_protocol_option = re.compile(r"-[-]?c(|onfig)\s+protocol\.", re.I)
135+
136+
unsafe_git_clone_options = [
137+
# This option allows users to execute arbitrary commands.
138+
# https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---upload-packltupload-packgt
139+
"--upload-pack",
140+
"-u",
141+
# Users can override configuration variables
142+
# like `protocol.allow` or `core.gitProxy` to execute arbitrary commands.
143+
# https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---configltkeygtltvaluegt
144+
"--config",
145+
"-c",
146+
]
137147

138148
# invariants
139149
# represents the configuration level of a configuration file
@@ -961,7 +971,7 @@ def blame(
961971
file: str,
962972
incremental: bool = False,
963973
rev_opts: Optional[List[str]] = None,
964-
**kwargs: Any
974+
**kwargs: Any,
965975
) -> List[List[Commit | List[str | bytes] | None]] | Iterator[BlameEntry] | None:
966976
"""The blame information for the given file at the given revision.
967977
@@ -1152,6 +1162,8 @@ def _clone(
11521162
odb_default_type: Type[GitCmdObjectDB],
11531163
progress: Union["RemoteProgress", "UpdateProgress", Callable[..., "RemoteProgress"], None] = None,
11541164
multi_options: Optional[List[str]] = None,
1165+
allow_unsafe_protocols: bool = False,
1166+
allow_unsafe_options: bool = False,
11551167
**kwargs: Any,
11561168
) -> "Repo":
11571169
odbt = kwargs.pop("odbt", odb_default_type)
@@ -1173,6 +1185,12 @@ def _clone(
11731185
multi = None
11741186
if multi_options:
11751187
multi = shlex.split(" ".join(multi_options))
1188+
1189+
if not allow_unsafe_protocols:
1190+
Git.check_unsafe_protocols(str(url))
1191+
if not allow_unsafe_options and multi_options:
1192+
Git.check_unsafe_options(options=multi_options, unsafe_options=cls.unsafe_git_clone_options)
1193+
11761194
proc = git.clone(
11771195
multi,
11781196
"--",
@@ -1221,27 +1239,13 @@ def _clone(
12211239
# END handle remote repo
12221240
return repo
12231241

1224-
@classmethod
1225-
def unsafe_options(
1226-
cls,
1227-
url: str,
1228-
multi_options: Optional[List[str]] = None,
1229-
) -> bool:
1230-
if "ext::" in url:
1231-
return True
1232-
if multi_options is not None:
1233-
if any(["--upload-pack" in m for m in multi_options]):
1234-
return True
1235-
if any([re.match(cls.re_config_protocol_option, m) for m in multi_options]):
1236-
return True
1237-
return False
1238-
12391242
def clone(
12401243
self,
12411244
path: PathLike,
12421245
progress: Optional[Callable] = None,
12431246
multi_options: Optional[List[str]] = None,
1244-
unsafe_protocols: bool = False,
1247+
allow_unsafe_protocols: bool = False,
1248+
allow_unsafe_options: bool = False,
12451249
**kwargs: Any,
12461250
) -> "Repo":
12471251
"""Create a clone from this repository.
@@ -1259,15 +1263,15 @@ def clone(
12591263
* All remaining keyword arguments are given to the git-clone command
12601264
12611265
:return: ``git.Repo`` (the newly cloned repo)"""
1262-
if not unsafe_protocols and self.unsafe_options(path, multi_options):
1263-
raise UnsafeOptionsUsedError(f"{path} requires unsafe_protocols flag")
12641266
return self._clone(
12651267
self.git,
12661268
self.common_dir,
12671269
path,
12681270
type(self.odb),
12691271
progress,
12701272
multi_options,
1273+
allow_unsafe_protocols=allow_unsafe_protocols,
1274+
allow_unsafe_options=allow_unsafe_options,
12711275
**kwargs,
12721276
)
12731277

@@ -1279,7 +1283,8 @@ def clone_from(
12791283
progress: Optional[Callable] = None,
12801284
env: Optional[Mapping[str, str]] = None,
12811285
multi_options: Optional[List[str]] = None,
1282-
unsafe_protocols: bool = False,
1286+
allow_unsafe_protocols: bool = False,
1287+
allow_unsafe_options: bool = False,
12831288
**kwargs: Any,
12841289
) -> "Repo":
12851290
"""Create a clone from the given URL
@@ -1300,9 +1305,17 @@ def clone_from(
13001305
git = cls.GitCommandWrapperType(os.getcwd())
13011306
if env is not None:
13021307
git.update_environment(**env)
1303-
if not unsafe_protocols and cls.unsafe_options(url, multi_options):
1304-
raise UnsafeOptionsUsedError(f"{url} requires unsafe_protocols flag")
1305-
return cls._clone(git, url, to_path, GitCmdObjectDB, progress, multi_options, **kwargs)
1308+
return cls._clone(
1309+
git,
1310+
url,
1311+
to_path,
1312+
GitCmdObjectDB,
1313+
progress,
1314+
multi_options,
1315+
allow_unsafe_protocols=allow_unsafe_protocols,
1316+
allow_unsafe_options=allow_unsafe_options,
1317+
**kwargs,
1318+
)
13061319

13071320
def archive(
13081321
self,

0 commit comments

Comments
 (0)