Skip to content

Commit 2625ed9

Browse files
s-t-e-v-e-n-kstsewd
authored andcommitted
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 787359d commit 2625ed9

File tree

3 files changed

+70
-1
lines changed

3 files changed

+70
-1
lines changed

‎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

‎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
@@ -1215,11 +1221,27 @@ def _clone(
12151221
# END handle remote repo
12161222
return repo
12171223

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+
12181239
def clone(
12191240
self,
12201241
path: PathLike,
12211242
progress: Optional[Callable] = None,
12221243
multi_options: Optional[List[str]] = None,
1244+
unsafe_protocols: bool = False,
12231245
**kwargs: Any,
12241246
) -> "Repo":
12251247
"""Create a clone from this repository.
@@ -1230,12 +1252,15 @@ def clone(
12301252
option per list item which is passed exactly as specified to clone.
12311253
For example ['--config core.filemode=false', '--config core.ignorecase',
12321254
'--recurse-submodule=repo1_path', '--recurse-submodule=repo2_path']
1255+
:param unsafe_protocols: Allow unsafe protocols to be used, like ext
12331256
:param kwargs:
12341257
* odbt = ObjectDatabase Type, allowing to determine the object database
12351258
implementation used by the returned Repo instance
12361259
* All remaining keyword arguments are given to the git-clone command
12371260
12381261
: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")
12391264
return self._clone(
12401265
self.git,
12411266
self.common_dir,
@@ -1254,6 +1279,7 @@ def clone_from(
12541279
progress: Optional[Callable] = None,
12551280
env: Optional[Mapping[str, str]] = None,
12561281
multi_options: Optional[List[str]] = None,
1282+
unsafe_protocols: bool = False,
12571283
**kwargs: Any,
12581284
) -> "Repo":
12591285
"""Create a clone from the given URL
@@ -1268,11 +1294,14 @@ def clone_from(
12681294
If you want to unset some variable, consider providing empty string
12691295
as its value.
12701296
:param multi_options: See ``clone`` method
1297+
:param unsafe_protocols: Allow unsafe protocols to be used, like ext
12711298
:param kwargs: see the ``clone`` method
12721299
:return: Repo instance pointing to the cloned directory"""
12731300
git = cls.GitCommandWrapperType(os.getcwd())
12741301
if env is not None:
12751302
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")
12761305
return cls._clone(git, url, to_path, GitCmdObjectDB, progress, multi_options, **kwargs)
12771306

12781307
def archive(

‎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)