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

git/exc.py

Lines changed: 4 additions & 0 deletions
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

Lines changed: 30 additions & 1 deletion
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(

test/test_repo.py

Lines changed: 36 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy