Skip to content

Commit fb7e089

Browse files
authored
Merge pull request #2232 from gitpython-developers/fix-submodule-move
Reject submodule move destinations through intermediate symlinks
2 parents 4905b4a + 2aca177 commit fb7e089

4 files changed

Lines changed: 531 additions & 30 deletions

File tree

doc/source/changes.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22
Changelog
33
=========
44

5+
3.1.63
6+
======
7+
8+
Security fixes for
9+
10+
* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-gq48-pqfc-9p58
11+
12+
If you can, also try and provide feedback on the upcoming v4 branch
13+
https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome.
14+
15+
See the following for all changes.
16+
https://github.com/gitpython-developers/GitPython/releases/tag/3.1.63
17+
518
3.1.62
619
======
720

git/objects/submodule/base.py

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import ntpath
1010
import os
1111
import os.path as osp
12+
from pathlib import Path
1213
import shlex
1314
import stat
1415
import sys
@@ -255,7 +256,7 @@ def _config_parser(
255256
# END handle parent_commit
256257
fp_module: Union[str, BytesIO]
257258
if not repo.bare and parent_matches_head and repo.working_tree_dir:
258-
fp_module = osp.join(repo.working_tree_dir, cls.k_modules_file)
259+
fp_module = cls._checked_abspath(repo.working_tree_dir, cls.k_modules_file)
259260
else:
260261
assert parent_commit is not None, "need valid parent_commit in bare repositories"
261262
try:
@@ -322,7 +323,7 @@ def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> Path
322323
if cls._need_gitfile_submodules(parent_repo.git):
323324
return osp.join(parent_repo.git_dir, "modules", name)
324325
if parent_repo.working_tree_dir:
325-
return osp.join(parent_repo.working_tree_dir, path)
326+
return cls._checked_abspath(parent_repo.working_tree_dir, cls._to_relative_path(parent_repo, path))
326327
raise NotADirectoryError()
327328

328329
@classmethod
@@ -361,8 +362,11 @@ def _clone_repo(
361362
:param kwargs:
362363
Additional arguments given to :manpage:`git-clone(1)`.
363364
"""
365+
path = cls._to_relative_path(repo, path)
366+
if repo.working_tree_dir is None:
367+
raise NotADirectoryError("Submodules require a working tree")
368+
module_checkout_path = cls._checked_abspath(repo.working_tree_dir, path)
364369
module_abspath = cls._module_abspath(repo, path, name)
365-
module_checkout_path = module_abspath
366370
if cls._need_gitfile_submodules(repo.git):
367371
if not allow_unsafe_options:
368372
Git.check_unsafe_options(Git._option_candidates([], kwargs), repo.unsafe_git_clone_options)
@@ -373,11 +377,22 @@ def _clone_repo(
373377
repo.unsafe_git_clone_options,
374378
)
375379
allow_unsafe_options = True
380+
if osp.islink(module_abspath):
381+
# Clone into the target while retaining the metadata alias. Git for
382+
# Windows cannot initialize through a dangling directory symlink.
383+
# Read the link explicitly for Python 3.7, and remove the Windows
384+
# namespace prefix returned by newer Python versions for Git.
385+
target = os.readlink(module_abspath)
386+
if sys.platform == "win32":
387+
if target.startswith("\\\\?\\UNC\\"):
388+
target = "\\\\" + target[8:]
389+
elif target.startswith("\\\\?\\"):
390+
target = target[4:]
391+
module_abspath = to_native_path_linux(osp.join(osp.dirname(module_abspath), target))
376392
kwargs["separate_git_dir"] = module_abspath
377393
module_abspath_dir = osp.dirname(module_abspath)
378394
if not osp.isdir(module_abspath_dir):
379395
os.makedirs(module_abspath_dir)
380-
module_checkout_path = osp.join(repo.working_tree_dir, path) # type: ignore[arg-type]
381396

382397
if url.startswith("../"):
383398
remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name
@@ -419,13 +434,43 @@ def abspath(self) -> PathLike:
419434
root = self.repo.working_tree_dir
420435
if root is None:
421436
return super().abspath
422-
path = root
423-
for component in os.fspath(self._to_relative_path(self.repo, self.path)).split("/"):
424-
path = join_path_native(path, component)
437+
return self._checkout_abspath(self._to_relative_path(self.repo, self.path))
438+
439+
def _checkout_abspath(self, relative_path: PathLike, allow_final_symlink: bool = False) -> PathLike:
440+
"""Check a checkout path already normalized by :meth:`_to_relative_path`."""
441+
return self._checked_abspath(self.repo.working_tree_dir, relative_path, allow_final_symlink)
442+
443+
@classmethod
444+
def _checked_abspath(
445+
cls, root: Union[PathLike, None], relative_path: PathLike, allow_final_symlink: bool = False
446+
) -> str:
447+
"""Reject symlinks below a trusted root before accessing submodule paths."""
448+
if root is None:
449+
raise NotADirectoryError("Submodules require a working tree")
450+
path = os.fspath(root)
451+
components = to_native_path_linux(relative_path).split("/")
452+
for index, component in enumerate(components):
453+
path = os.fspath(join_path_native(path, component))
454+
if allow_final_symlink and index == len(components) - 1:
455+
break
425456
if osp.islink(path):
426-
raise ValueError("Submodule checkout path %r contains a symbolic link" % self.path)
457+
raise ValueError("Submodule path %r contains a symbolic link" % relative_path)
427458
return path
428459

460+
@staticmethod
461+
def _renames(source: PathLike, destination: PathLike) -> None:
462+
os.makedirs(osp.dirname(destination), exist_ok=True)
463+
os.rename(source, destination)
464+
# Match renames() cleanup, but stop before directory symlinks: Windows
465+
# rmdir() removes the link even when its target is nonempty.
466+
parent = osp.dirname(source)
467+
while parent and not osp.islink(parent):
468+
try:
469+
os.rmdir(parent)
470+
except OSError:
471+
break
472+
parent = osp.dirname(parent)
473+
429474
@classmethod
430475
def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_abspath: PathLike) -> None:
431476
"""Write a ``.git`` file containing a (preferably) relative path to the actual
@@ -449,14 +494,19 @@ def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_ab
449494
:param module_abspath:
450495
Absolute path to the bare repository.
451496
"""
497+
# Git resolves metadata symlinks before interpreting core.worktree.
498+
# Path.resolve() also handles Windows symlinks on Python 3.7.
499+
module_abspath = str(Path(module_abspath).resolve())
500+
working_tree_dir = str(Path(working_tree_dir).resolve())
452501
git_file = osp.join(working_tree_dir, ".git")
502+
module_config = osp.join(module_abspath, "config")
453503
rela_path = osp.relpath(module_abspath, start=working_tree_dir)
454504
if sys.platform == "win32" and osp.isfile(git_file):
455505
os.remove(git_file)
456506
with open(git_file, "wb") as fp:
457507
fp.write(("gitdir: %s" % rela_path).encode(defenc))
458508

459-
with GitConfigParser(osp.join(module_abspath, "config"), read_only=False, merge_includes=False) as writer:
509+
with GitConfigParser(module_config, read_only=False, merge_includes=False) as writer:
460510
writer.set_value(
461511
"core",
462512
"worktree",
@@ -567,6 +617,8 @@ def add(
567617
name,
568618
url="invalid-temporary",
569619
)
620+
cls._checked_abspath(repo.working_tree_dir, cls.k_modules_file)
621+
sm._checkout_abspath(path)
570622
if sm.exists():
571623
# Reretrieve submodule from tree.
572624
try:
@@ -661,7 +713,9 @@ def add(
661713

662714
# We deliberately assume that our head matches our index!
663715
if mrepo:
664-
sm.binsha = mrepo.head.commit.binsha
716+
# Release cat-file processes before callers move the checkout on Windows.
717+
with mrepo:
718+
sm.binsha = mrepo.head.commit.binsha
665719
index.add([sm], write=True)
666720

667721
return sm
@@ -1039,7 +1093,8 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10391093
self
10401094
10411095
:raise ValueError:
1042-
If the module path existed and was not empty, or was a file.
1096+
If the module path existed and was not empty, was a file, or had a
1097+
symbolic link in an intermediate component.
10431098
10441099
:note:
10451100
Currently the method is not atomic, and it could leave the repository in an
@@ -1057,7 +1112,11 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10571112
return self
10581113
# END handle no change
10591114

1060-
module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path)
1115+
if configuration:
1116+
self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file)
1117+
# Validate the source before removing the destination.
1118+
cur_path = self.abspath
1119+
module_checkout_abspath = self._checkout_abspath(module_checkout_path, allow_final_symlink=True)
10611120
if osp.isfile(module_checkout_abspath):
10621121
raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath)
10631122
# END handle target files
@@ -1089,10 +1148,9 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10891148
# END handle module
10901149

10911150
# Move the module into place if possible.
1092-
cur_path = self.abspath
10931151
renamed_module = False
10941152
if module and osp.exists(cur_path):
1095-
os.renames(cur_path, module_checkout_abspath)
1153+
self._renames(cur_path, module_checkout_abspath)
10961154
renamed_module = True
10971155

10981156
if osp.isfile(osp.join(module_checkout_abspath, ".git")):
@@ -1123,7 +1181,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
11231181
# END handle configuration flag
11241182
except Exception:
11251183
if renamed_module:
1126-
os.renames(module_checkout_abspath, cur_path)
1184+
self._renames(module_checkout_abspath, cur_path)
11271185
# END undo module renaming
11281186
raise
11291187
# END handle undo rename
@@ -1180,6 +1238,12 @@ def remove(
11801238
Doesn't work atomically, as failure to remove any part of the submodule will
11811239
leave an inconsistent state.
11821240
1241+
:note:
1242+
Metadata-directory aliases under ``.git/modules`` are retained. A link
1243+
directly to the deleted repository becomes dangling; adding or initializing
1244+
the submodule again recreates its target. Linked parent directories remain
1245+
available to sibling submodules.
1246+
11831247
:raise git.exc.InvalidGitRepositoryError:
11841248
Thrown if the repository cannot be deleted.
11851249
@@ -1191,6 +1255,8 @@ def remove(
11911255
# END handle parameters
11921256

11931257
self._validated_name(self.name)
1258+
if configuration:
1259+
self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file)
11941260
# Recursively remove children of this submodule.
11951261
nc = 0
11961262
for csm in self.children():
@@ -1209,7 +1275,7 @@ def remove(
12091275
################################
12101276
if module and self.module_exists():
12111277
mod = self.module()
1212-
git_dir = mod.git_dir
1278+
git_dir = str(Path(mod.git_dir).resolve())
12131279
if force:
12141280
# Take the fast lane and just delete everything in our module path.
12151281
# TODO: If we run into permission problems, we have a highly
@@ -1450,6 +1516,9 @@ def rename(self, new_name: str) -> "Submodule":
14501516

14511517
self._validated_name(self.name)
14521518
self._validated_name(new_name)
1519+
destination_module_abspath = self._module_abspath(self.repo, self.path, new_name)
1520+
mod = self.module()
1521+
self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file)
14531522

14541523
# .git/config
14551524
with self.repo.config_writer() as pw:
@@ -1466,17 +1535,15 @@ def rename(self, new_name: str) -> "Submodule":
14661535
self._name = new_name
14671536

14681537
# .git/modules
1469-
mod = self.module()
14701538
if mod.has_separate_working_tree():
1471-
destination_module_abspath = self._module_abspath(self.repo, self.path, new_name)
14721539
source_dir = mod.git_dir
14731540
# Let's be sure the submodule name is not so obviously tied to a directory.
14741541
if str(destination_module_abspath).startswith(str(mod.git_dir)):
14751542
tmp_dir = self._module_abspath(self.repo, self.path, str(uuid.uuid4()))
1476-
os.renames(source_dir, tmp_dir)
1543+
self._renames(source_dir, tmp_dir)
14771544
source_dir = tmp_dir
14781545
# END handle self-containment
1479-
os.renames(source_dir, destination_module_abspath)
1546+
self._renames(source_dir, destination_module_abspath)
14801547
if mod.working_tree_dir:
14811548
self._write_git_file_and_module_config(mod.working_tree_dir, destination_module_abspath)
14821549
# END move separate git repository

test/test_remote.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,14 +1090,16 @@ def test_fetch_unsafe_branch_name(self, rw_repo, remote_repo):
10901090
class TestTimeouts(TestBase):
10911091
@with_rw_repo("HEAD", bare=False)
10921092
def test_timeout_funcs(self, repo):
1093+
# Maintenance may outlive a timed-out fetch and race with fixture cleanup.
1094+
with repo.config_writer() as config:
1095+
config.set_value("maintenance", "auto", False)
1096+
config.set_value("gc", "auto", 0) # Older Git versions use auto-gc.
1097+
10931098
# Force error code to prevent a race condition if the python thread is slow.
1094-
default = Git.AutoInterrupt._status_code_if_terminate
1095-
Git.AutoInterrupt._status_code_if_terminate = -15
1096-
for function in ["pull", "fetch"]: # Can't get push to time out.
1097-
f = getattr(repo.remotes.origin, function)
1098-
assert f is not None # Make sure these functions exist.
1099-
_ = f() # Make sure the function runs.
1100-
with pytest.raises(GitCommandError, match="kill_after_timeout=0 s"):
1101-
f(kill_after_timeout=0)
1102-
1103-
Git.AutoInterrupt._status_code_if_terminate = default
1099+
with mock.patch.object(Git.AutoInterrupt, "_status_code_if_terminate", -15):
1100+
for function in ["pull", "fetch"]: # Can't get push to time out.
1101+
f = getattr(repo.remotes.origin, function)
1102+
assert f is not None # Make sure these functions exist.
1103+
_ = f() # Make sure the function runs.
1104+
with pytest.raises(GitCommandError, match="kill_after_timeout=0 s"):
1105+
f(kill_after_timeout=0)

0 commit comments

Comments
 (0)