GitPython submodule update path traversal can write outside the repository
Affected: GitPython 3.1.61 (latest release) and main — git/objects/submodule/base.py. git diff 3.1.61 origin/main -- git/objects/submodule/ is empty, so both are identical here.
The gap
The fix for GHSA-hmq2-w58f-27jc added Submodule._validated_name() and wired it into update() and five siblings, closing the .gitmodules name → .git/modules/<name> traversal. The other attacker-controlled .gitmodules field, path, is read raw:
# git/objects/submodule/base.py:172-177
def _set_cache_(self, attr):
if attr in ("path", "_url", "_branch_path"):
reader = self.config_reader()
self.path = reader.get("path") # raw .gitmodules value
and GitPython's own containment guard is applied in only two of the places that consume it:
400: def _to_relative_path(cls, parent_repo, path) # the guard (abspath + commonpath containment)
542: path = cls._to_relative_path(repo, path) # add() — guarded
1041: module_checkout_path = self._to_relative_path(self.repo, module_path) # move() — guarded
update() validates only the name and then uses the path-derived absolute location directly:
788: self._validated_name(self.name) # NAME only
801: checkout_module_abspath = self.abspath # derived from self.path — unguarded
821: os.makedirs(checkout_module_abspath, exist_ok=True)
So path = ../../../tmp/escaped in an attacker-authored .gitmodules selects the directory that gets created and, on the clone path, populated from the submodule URL. The same absolute location is what force_remove hands to shutil.rmtree.
The asymmetry is the argument: this is not a missing concept — the project wrote _to_relative_path() precisely for this, and add()/move() use it. update() does not.
Honest limits (please read before rating)
- The most common flow is not affected.
Repo.clone_from(...)→repo.submodules→sm.update(init=True)re-derivespathfrom a canonical tree lookup, and real git refuses to check out a tree containing a..component, so an evil.gitmodulesnever lands in the working tree in the first place. A reachable trigger therefore requires the victim's code to name a non-HEAD commit (a historical-commit API such assubmodule_update(previous_commit=...)). - The researcher did not build that end-to-end trigger. The researcher only verified first-hand the code above: the guard's two call sites, the name-only validation in
update(), and the unguardedabspath→os.makedirs()flow at 3.1.61 ==main.
Suggested fix
Apply the guard the project already has, wherever the path is consumed:
# in update(), before deriving abspath (and in any other consumer of self.path):
checkout_rel = self._to_relative_path(self.repo, self.path) # raises if it escapes the working tree
Better still, validate at the boundary: reject a .gitmodules entry whose path is absolute or contains a .. component when the section is first read in _set_cache_()/iter_items(), so no consumer can be added later without the check. A regression test with path = ../escaped alongside the existing name test would pin both fields.
Prior art checked
GHSA-hmq2-w58f-27jc (this is a residual of its fix, in the sibling field, not a re-report) plus the repository's 30 published advisories — none mentions the path field or _to_relative_path. Searched issues and PRs for _to_relative_path, gitmodules path and submodule traversal: no report of this.
Credit
kta1kri.
Appendix — EVIDENCE_gitpython_path_unguarded_20260901.txt (inlined; advisories accept no attachments)
=== EVIDENCE: GitPython — the .gitmodules 'path' field reaches os.makedirs()/clone unguarded ===
Mon Aug 31 18:45:22 UTC 2026
--- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty ---
--- the containment guard GitPython owns, and its only two call sites ---
33: _to_relative_path,
400: def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike:
407: path = _to_relative_path(parent_repo.working_tree_dir, path)
542: path = cls._to_relative_path(repo, path)
1041: module_checkout_path = self._to_relative_path(self.repo, module_path)
--- the parent fix (_validated_name) call sites: it validates the NAME ---
309: def _validated_name(cls, name: str) -> str:
321: name = cls._validated_name(name)
541: cls._validated_name(name)
788: self._validated_name(self.name)
1040: self._validated_name(self.name)
1181: self._validated_name(self.name)
1439: self._validated_name(self.name)
1440: self._validated_name(new_name)
1489: self._validated_name(self.name)
--- update(): name validated, path not; abspath -> os.makedirs ---
try:
self._validated_name(self.name)
# ENSURE REPO IS PRESENT AND UP-TO-DATE
# END early abort if init is not allowed
checkout_module_abspath = self.abspath
module_abspath = self._module_abspath(self.repo, self.path, self.name)
# ``git submodule deinit`` leaves the repository in
# ``.git/modules`` and empties the checkout. Reconnect that retained
# repository instead of trying to clone over it.
if not dry_run and osp.isdir(module_abspath):
try:
git.Repo(module_abspath)
except InvalidGitRepositoryError:
pass
else:
if osp.lexists(checkout_module_abspath) and (
osp.islink(checkout_module_abspath)
or not osp.isdir(checkout_module_abspath)
or os.listdir(checkout_module_abspath)
):
raise OSError(
"Module directory at %r does already exist and is non-empty" % checkout_module_abspath
)
os.makedirs(checkout_module_abspath, exist_ok=True)
self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)
mrepo = git.Repo(checkout_module_abspath)
--- where self.path comes from (raw .gitmodules value) ---
def _set_cache_(self, attr: str) -> None:
if attr in ("path", "_url", "_branch_path"):
reader: SectionConstraint = self.config_reader()
# Default submodule values.
try:
self.path = reader.get("path")
except cp.NoSectionError as e:
Betroffene Software
Klicken Sie auf eine Technologie, um alle zugehörigen Schwachstellen anzuzeigen.
| Plattform | Paket | Anfällige Versionen | Behoben in |
|---|---|---|---|
| Python (PyPI) | gitpython | < 3.1.62 | 3.1.62 |
Verweise
- https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w
- https://github.com/gitpython-developers/GitPython/pull/2225
- https://github.com/gitpython-developers/GitPython/commit/1ed0ebc2f2e74d979cdc367a4864a7731fdcc093
- https://github.com/gitpython-developers/GitPython
- https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62
- CVSS-Vektor
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N- Schwäche (CWE)
CWE-22, CWE-73- Plattform
- Python (PyPI)
- Paket
gitpython- Veröffentlicht
- 30.09.2026
- Aktualisiert
- 11.10.2026 15:18
Quellen und Lizenzen
OSV / GitHub Advisory Database - GHSA-59cr-6r3x-644w - CC BY 4.0
Schwachstellenhistorie: gitpython
Alle veröffentlichten Schwachstellen dieses Pakets (Python (PyPI)), neueste zuerst. Zeigt, ob es ein einmaliger Ausrutscher ist oder ein Paket, das man im Auge behalten muss.
- Einträge insgesamt
- 37
- Erste → letzte
- 12.2022 09.2026
- Ohne Fix
- 0
- In Angriffen ausgenutzt
- 0
Verteilung nach Schweregrad
- Kritisch 3
- Hoch 26
- Mittel 8
Veröffentlichungen pro Jahr
- 2022: 1
- 2023: 3
- 2024: 1
- 2025: 0
- 2026: 32
-
GitPython submodule update path traversal can write outside the repository dieser Eintrag
Erkennen Sie Schwachstellen in Ihrer Anwendung
Mit der DockRay-Integration und aktivierter Softwareinventarisierung prüft DockRay gemeldete Komponentenversionen auf bekannte Schwachstellen und benachrichtigt Sie über bestätigte Treffer. Die Abdeckung hängt von der unterstützten Technologie und den verfügbaren Meldungen ab.