Skip to content

Commit 24215ac

Browse files
committed
Forbid unsafe protocol URLs in Repo.clone{,_from}()
Since the URL is passed directly to git clone, and the remote-ext helper will happily execute shell commands, so by default disallow URLs that contain a "::" unless a new unsafe_protocols kwarg is passed. (CVE-2022-24439) Fixes #1515
1 parent 17ff263 commit 24215ac

File tree

3 files changed

+70
-1
lines changed

3 files changed

+70
-1
lines changed

Diff for: git/exc.py

+4
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ 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."""
42+
43+
4044
class CommandError(GitError):
4145
"""Base class for exceptions thrown at every stage of `Popen()` execution.
4246

Diff for: git/repo/base.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
)
2222
from git.config import GitConfigParser
2323
from git.db import GitCmdObjectDB
24-
from git.exc import InvalidGitRepositoryError, NoSuchPathError, GitCommandError
24+
from git.exc import (
25+
GitCommandError,
26+
InvalidGitRepositoryError,
27+
NoSuchPathError,
28+
UnsafeOptionsUsedError,
29+
)
2530
from git.index import IndexFile
2631
from git.objects import Submodule, RootModule, Commit
2732
from git.refs import HEAD, Head, Reference, TagReference
@@ -128,6 +133,7 @@ class Repo(object):
128133
re_envvars = re.compile(r"(\$(\{\s?)?[a-zA-Z_]\w*(\}\s?)?|%\s?[a-zA-Z_]\w*\s?%)")
129134
re_author_committer_start = re.compile(r"^(author|committer)")
130135
re_tab_full_line = re.compile(r"^\t(.*)$")
136+
re_config_protocol_option = re.compile(r"-[-]?c(|onfig)\s+protocol\.", re.I)
131137

132138
# invariants
133139
# represents the configuration level of a configuration file
@@ -1214,11 +1220,27 @@ def _clone(
12141220
# END handle remote repo
12151221
return repo
12161222

1223+
@classmethod
1224+
def unsafe_options(
1225+
cls,
1226+
url: str,
1227+
multi_options: Optional[List[str]] = None,
1228+
) -> bool:
1229+
if "ext::" in url:
1230+
return True
1231+
if multi_options is not None:
1232+
if any(["--upload-pack" in m for m in multi_options]):
1233+
return True
1234+
if any([re.match(cls.re_config_protocol_option, m) for m in multi_options]):
1235+
return True
1236+
return False
1237+
12171238
def clone(
12181239
self,
12191240
path: PathLike,
12201241
progress: Optional[Callable] = None,
12211242
multi_options: Optional[List[str]] = None,
1243+
unsafe_protocols: bool = False,
12221244
**kwargs: Any,
12231245
) -> "Repo":
12241246
"""Create a clone from this repository.
@@ -1229,12 +1251,15 @@ def clone(
12291251
option per list item which is passed exactly as specified to clone.
12301252
For example ['--config core.filemode=false', '--config core.ignorecase',
12311253
'--recurse-submodule=repo1_path', '--recurse-submodule=repo2_path']
1254+
:param unsafe_protocols: Allow unsafe protocols to be used, like ext
12321255
:param kwargs:
12331256
* odbt = ObjectDatabase Type, allowing to determine the object database
12341257
implementation used by the returned Repo instance
12351258
* All remaining keyword arguments are given to the git-clone command
12361259
12371260
:return: ``git.Repo`` (the newly cloned repo)"""
1261+
if not unsafe_protocols and self.unsafe_options(path, multi_options):
1262+
raise UnsafeOptionsUsedError(f"{path} requires unsafe_protocols flag")
12381263
return self._clone(
12391264
self.git,
12401265
self.common_dir,
@@ -1253,6 +1278,7 @@ def clone_from(
12531278
progress: Optional[Callable] = None,
12541279
env: Optional[Mapping[str, str]] = None,
12551280
multi_options: Optional[List[str]] = None,
1281+
unsafe_protocols: bool = False,
12561282
**kwargs: Any,
12571283
) -> "Repo":
12581284
"""Create a clone from the given URL
@@ -1267,11 +1293,14 @@ def clone_from(
12671293
If you want to unset some variable, consider providing empty string
12681294
as its value.
12691295
:param multi_options: See ``clone`` method
1296+
:param unsafe_protocols: Allow unsafe protocols to be used, like ext
12701297
:param kwargs: see the ``clone`` method
12711298
:return: Repo instance pointing to the cloned directory"""
12721299
git = cls.GitCommandWrapperType(os.getcwd())
12731300
if env is not None:
12741301
git.update_environment(**env)
1302+
if not unsafe_protocols and cls.unsafe_options(url, multi_options):
1303+
raise UnsafeOptionsUsedError(f"{url} requires unsafe_protocols flag")
12751304
return cls._clone(git, url, to_path, GitCmdObjectDB, progress, multi_options, **kwargs)
12761305

12771306
def archive(

Diff for: test/test_repo.py

+36
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import pickle
1414
import sys
1515
import tempfile
16+
import uuid
1617
from unittest import mock, skipIf, SkipTest
1718

1819
import pytest
@@ -37,6 +38,7 @@
3738
)
3839
from git.exc import (
3940
BadObject,
41+
UnsafeOptionsUsedError,
4042
)
4143
from git.repo.fun import touch
4244
from test.lib import TestBase, with_rw_repo, fixture
@@ -263,6 +265,40 @@ def test_leaking_password_in_clone_logs(self, rw_dir):
263265
to_path=rw_dir,
264266
)
265267

268+
def test_unsafe_options(self):
269+
self.assertFalse(Repo.unsafe_options("github.com/deploy/deploy"))
270+
271+
def test_unsafe_options_ext_url(self):
272+
self.assertTrue(Repo.unsafe_options("ext::ssh"))
273+
274+
def test_unsafe_options_multi_options_upload_pack(self):
275+
self.assertTrue(Repo.unsafe_options("", ["--upload-pack='touch foo'"]))
276+
277+
def test_unsafe_options_multi_options_config_user(self):
278+
self.assertFalse(Repo.unsafe_options("", ["--config user"]))
279+
280+
def test_unsafe_options_multi_options_config_protocol(self):
281+
self.assertTrue(Repo.unsafe_options("", ["--config protocol.foo"]))
282+
283+
def test_clone_from_forbids_helper_urls_by_default(self):
284+
with self.assertRaises(UnsafeOptionsUsedError):
285+
Repo.clone_from("ext::sh -c touch% /tmp/foo", "tmp")
286+
287+
@with_rw_repo("HEAD")
288+
def test_clone_from_allow_unsafe(self, repo):
289+
bad_filename = pathlib.Path(f'{tempfile.gettempdir()}/{uuid.uuid4()}')
290+
bad_url = f'ext::sh -c touch% {bad_filename}'
291+
try:
292+
repo.clone_from(
293+
bad_url, 'tmp',
294+
multi_options=["-c protocol.ext.allow=always"],
295+
unsafe_protocols=True
296+
)
297+
except GitCommandError:
298+
pass
299+
self.assertTrue(bad_filename.is_file())
300+
bad_filename.unlink()
301+
266302
@with_rw_repo("HEAD")
267303
def test_max_chunk_size(self, repo):
268304
class TestOutputStream(TestBase):

0 commit comments

Comments
 (0)