Skip to content

Commit a088524

Browse files
Byroncodex
andcommitted
fix: Reject submodule move destinations through intermediate symlinks
<!-- agent --> Submodule.move() checked lexical containment but did not validate intermediate destination components before filesystem and repository updates. GHSA-gq48-pqfc-9p58 identifies the resulting checkout-path boundary violation. The new regression failed before the fix because move() returned successfully. Share the existing abspath component walk with move() and validate the normalized destination before any mutation, including configuration-only and module-only calls. Preserve the no-op early return and existing final-component symlink handling; abspath still rejects every symlink component. This addresses pre-existing links, not concurrent directory replacement races. The Git reference checkout at 1630431f326e15fcde608827b5ff38422528eb59 uses has_symlink_leading_path() in builtin/mv.c and tests rejection without index changes in t/t7001-mv.sh. The fix follows that intermediate-component rule while retaining GitPython leaf-link compatibility. Validation: the 30 new parameterized cases pass, covering relative and absolute destinations and link targets, internal and dangling links, all move flag combinations, unchanged repository state after rejection, ordinary and no-op moves, and leaf-link compatibility. The complete test/test_submodule.py suite passes: 75 passed, 3 skipped, 1 xfailed. Test-process commit.gpgsign=false avoids sandbox GPG failures. Ruff lint and format checks, mypy (45 source files), and git diff --check pass. Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 <codex@openai.com>
1 parent b62e91b commit a088524

3 files changed

Lines changed: 153 additions & 5 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: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -419,11 +419,20 @@ def abspath(self) -> PathLike:
419419
root = self.repo.working_tree_dir
420420
if root is None:
421421
return super().abspath
422-
path = root
423-
for component in os.fspath(self._to_relative_path(self.repo, self.path)).split("/"):
422+
return self._checkout_abspath(self._to_relative_path(self.repo, self.path))
423+
424+
def _checkout_abspath(self, relative_path: PathLike, allow_final_symlink: bool = False) -> PathLike:
425+
"""Check a checkout path already normalized by :meth:`_to_relative_path`."""
426+
path = self.repo.working_tree_dir
427+
if path is None:
428+
raise NotADirectoryError("Submodules require a working tree")
429+
components = os.fspath(relative_path).split("/")
430+
for index, component in enumerate(components):
424431
path = join_path_native(path, component)
432+
if allow_final_symlink and index == len(components) - 1:
433+
break
425434
if osp.islink(path):
426-
raise ValueError("Submodule checkout path %r contains a symbolic link" % self.path)
435+
raise ValueError("Submodule checkout path %r contains a symbolic link" % relative_path)
427436
return path
428437

429438
@classmethod
@@ -1039,7 +1048,8 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10391048
self
10401049
10411050
:raise ValueError:
1042-
If the module path existed and was not empty, or was a file.
1051+
If the module path existed and was not empty, was a file, or had a
1052+
symbolic link in an intermediate component.
10431053
10441054
:note:
10451055
Currently the method is not atomic, and it could leave the repository in an
@@ -1057,7 +1067,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10571067
return self
10581068
# END handle no change
10591069

1060-
module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path)
1070+
module_checkout_abspath = self._checkout_abspath(module_checkout_path, allow_final_symlink=True)
10611071
if osp.isfile(module_checkout_abspath):
10621072
raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath)
10631073
# END handle target files

test/test_submodule.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,131 @@ def _patch_git_config(name, value):
5151
yield
5252

5353

54+
@pytest.fixture
55+
def movable_submodule(tmp_path):
56+
"""Create a committed local submodule whose logical name stays fixed when moved."""
57+
with git.Repo.init(tmp_path / "source") as source, git.Repo.init(tmp_path / "parent") as parent:
58+
(tmp_path / "source" / "file").write_text("content", encoding="utf-8")
59+
source.index.add(["file"])
60+
source.index.commit("Create source")
61+
with _patch_git_config("protocol.file.allow", "always"):
62+
submodule = parent.create_submodule("logical-name", "module", source.working_tree_dir)
63+
parent.index.commit("Create submodule")
64+
# Release clone handles before Windows moves the checkout.
65+
submodule.module().close()
66+
yield submodule
67+
68+
69+
def _move_snapshot(submodule):
70+
"""Capture index, configuration, and path state to detect side effects of rejected moves."""
71+
parent = submodule.repo
72+
with submodule.module() as module:
73+
config = Path(module.git_dir, "config").read_bytes()
74+
return (
75+
Path(parent.index.path).read_bytes(),
76+
Path(parent.working_tree_dir, ".gitmodules").read_bytes(),
77+
Path(submodule.abspath, ".git").read_bytes(),
78+
config,
79+
submodule.path,
80+
)
81+
82+
83+
@pytest.mark.parametrize("target_kind", ["relative", "absolute", "internal", "dangling"])
84+
@pytest.mark.parametrize("configuration,module", [(True, True), (False, True), (True, False)])
85+
@pytest.mark.parametrize("absolute_path", [False, True])
86+
def test_move_rejects_intermediate_symlink(
87+
movable_submodule, tmp_path, target_kind, configuration, module, absolute_path
88+
):
89+
"""Reject intermediate symlinks before changing repository state or their targets.
90+
91+
Cover relative and absolute destinations in every move mode, including links
92+
within the repository and dangling links, which must also be rejected.
93+
"""
94+
submodule = movable_submodule
95+
parent = submodule.repo
96+
root = Path(parent.working_tree_dir)
97+
target = root / "target" if target_kind == "internal" else tmp_path / "outside"
98+
if target_kind != "dangling":
99+
target.mkdir()
100+
(root / "nested").mkdir()
101+
link = root / "nested" / "link"
102+
link.symlink_to(
103+
target if target_kind == "absolute" else os.path.relpath(target, link.parent), target_is_directory=True
104+
)
105+
parent.index.add(["nested/link"])
106+
parent.index.commit("Record layout")
107+
tree = parent.git.write_tree()
108+
before = _move_snapshot(submodule)
109+
destination = root / "nested/link/new/moved" if absolute_path else "nested/link/new/moved"
110+
111+
with pytest.raises(ValueError, match="contains a symbolic link"):
112+
submodule.move(destination, configuration=configuration, module=module)
113+
114+
assert _move_snapshot(submodule) == before
115+
assert parent.git.write_tree() == tree
116+
assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content"
117+
if target_kind == "dangling":
118+
assert not target.exists()
119+
else:
120+
assert list(target.iterdir()) == []
121+
122+
123+
@pytest.mark.parametrize("absolute_path", [False, True])
124+
def test_move_normal_destination(movable_submodule, absolute_path):
125+
"""Allow ordinary relative and absolute moves, and make a repeated move a no-op."""
126+
submodule = movable_submodule
127+
root = Path(submodule.repo.working_tree_dir)
128+
destination = root / "nested/moved" if absolute_path else "nested/moved"
129+
assert submodule.move(destination) is submodule
130+
assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content"
131+
assert not (root / "module").exists()
132+
assert submodule.path == "nested/moved"
133+
submodule.repo.git.write_tree()
134+
before = _move_snapshot(submodule)
135+
assert submodule.move(destination) is submodule
136+
assert _move_snapshot(submodule) == before
137+
138+
139+
@pytest.mark.parametrize("kind", ["empty", "nonempty", "file", "dangling"])
140+
def test_move_leaf_symlink_compatibility(movable_submodule, tmp_path, kind):
141+
"""Preserve leaf-symlink replacement without modifying the external target.
142+
143+
Moving onto a link to an empty directory replaces the link; nonempty, file,
144+
and dangling targets fail without changing repository state. Direct checkout
145+
path access must still reject every leaf symlink.
146+
"""
147+
submodule = movable_submodule
148+
root = Path(submodule.repo.working_tree_dir)
149+
target = tmp_path / "outside"
150+
if kind in ("empty", "nonempty"):
151+
target.mkdir()
152+
if kind == "nonempty":
153+
(target / "keep").write_text("keep", encoding="utf-8")
154+
if kind == "file":
155+
target.write_text("keep", encoding="utf-8")
156+
destination = root / "destination"
157+
destination.symlink_to(target, target_is_directory=kind != "file")
158+
with pytest.raises(ValueError, match="contains a symbolic link"):
159+
Submodule(submodule.repo, Submodule.NULL_BIN_SHA, name="unused", path="destination").abspath
160+
before = _move_snapshot(submodule)
161+
if kind == "empty":
162+
assert submodule.move("destination") is submodule
163+
assert not destination.is_symlink()
164+
assert (destination / "file").is_file()
165+
assert list(target.iterdir()) == []
166+
else:
167+
with pytest.raises(OSError if kind == "dangling" else ValueError):
168+
submodule.move("destination")
169+
assert _move_snapshot(submodule) == before
170+
assert destination.is_symlink()
171+
if kind == "nonempty":
172+
assert (target / "keep").read_text(encoding="utf-8") == "keep"
173+
elif kind == "file":
174+
assert target.read_text(encoding="utf-8") == "keep"
175+
else:
176+
assert not target.exists()
177+
178+
54179
class TestRootProgress(RootUpdateProgress):
55180
"""Just prints messages, for now without checking the correctness of the states"""
56181

0 commit comments

Comments
 (0)